mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
Compare commits
320 Commits
v0.9.0
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a780384d0 | |||
| b792a94b84 | |||
| 29d763e210 | |||
| 37d6796230 | |||
| 0493960985 | |||
| bc32fbbc98 | |||
| f3fa0e8f5b | |||
| 2163a7a092 | |||
| 0faf9fb4e8 | |||
| f89e57a732 | |||
| 37924b295b | |||
| bead5746c3 | |||
| 21db84e8dc | |||
| 0fd4fb1f7a | |||
| 5ea87a498a | |||
| 9eebd96ab7 | |||
| c252915083 | |||
| 36b577bff8 | |||
| 7768168dd2 | |||
| 97111778a9 | |||
| f722498dba | |||
| 27a7a3b439 | |||
| e4d69fa140 | |||
| 0fda210e8b | |||
| cdb2ea2207 | |||
| e8d7bb4c06 | |||
| 75f377fd4d | |||
| 8756f8d77f | |||
| d502fc1bf5 | |||
| 43536ba326 | |||
| c321388a21 | |||
| 1ce3a917a5 | |||
| c0e9dc1818 | |||
| 764939ed90 | |||
| 4cbfdc970c | |||
| 642e1c39b1 | |||
| c4998bb102 | |||
| 0beb389223 | |||
| 9e6627f0c3 | |||
| 3edab0fe68 | |||
| 129a3aeb38 | |||
| dcae21b24c | |||
| 62e1e02036 | |||
| 8a58cde3ee | |||
| e733d05a5e | |||
| 8a9fc05451 | |||
| 31bbde9cdd | |||
| e0514c3692 | |||
| f79e0f82dd | |||
| 131c1ff0e9 | |||
| 414e6a1627 | |||
| 431bb5a0c9 | |||
| 6878f0126d | |||
| 52127a3323 | |||
| 11d9038927 | |||
| a15e7c2dc1 | |||
| 4518c3eb03 | |||
| 44cb7243c5 | |||
| 162f33f81e | |||
| 180b96e21a | |||
| 3fda1db9c0 | |||
| 8910c8c454 | |||
| c83eca5e88 | |||
| 594496a225 | |||
| 56ba1332f3 | |||
| 1d4f2beeea | |||
| 5160d8d4d1 | |||
| e2500035d5 | |||
| 886ba78b11 | |||
| d0997f6279 | |||
| 91c4bc01f1 | |||
| fb2db2c304 | |||
| ba3ab5a7d9 | |||
| a4ebcb6e96 | |||
| f168862eaa | |||
| 44cc824b28 | |||
| 937359b4df | |||
| 55dc6545f5 | |||
| fddefc78a1 | |||
| 3f431063e2 | |||
| 18d7ad3a36 | |||
| 201a02839e | |||
| f0be8cbaa5 | |||
| 9f80289a89 | |||
| ae9b06976c | |||
| 6e6035141a | |||
| aba1770701 | |||
| cba33d2a6f | |||
| 2ebbdadcd6 | |||
| 48cfa8fb6c | |||
| 93aa942e1e | |||
| c5b1242980 | |||
| f0562915db | |||
| 818ce2698f | |||
| 36e7556a02 | |||
| 069552e921 | |||
| f7c3a4e81d | |||
| a2dcd96fee | |||
| 3c961b7bd7 | |||
| f2ec46480e | |||
| 3956610134 | |||
| fc41e160db | |||
| 158395bfdf | |||
| f3232000f4 | |||
| cb32032f76 | |||
| 86a702b3b0 | |||
| d4764e3682 | |||
| 8372f5bd8f | |||
| 98d67559d1 | |||
| 6bd3313a22 | |||
| f11de37bf2 | |||
| 4e4e0ad656 | |||
| a971fc8877 | |||
| 7565248f61 | |||
| c69a1f11af | |||
| 716ee46471 | |||
| 7082a50365 | |||
| 9a588780b9 | |||
| 06edff1af2 | |||
| 163c5f70b3 | |||
| ca5e62f775 | |||
| 07e08352b9 | |||
| 8f40b91dea | |||
| d1662e77cf | |||
| dc4982464d | |||
| fe883d5362 | |||
| 5fbd6514c2 | |||
| 5aaae93367 | |||
| 46e014a8ca | |||
| ea21803acb | |||
| 10367b384e | |||
| ce8bc27db8 | |||
| 7318a10828 | |||
| b15d0bd114 | |||
| 34628720f2 | |||
| 85223fc1c9 | |||
| dc80855540 | |||
| 0b1c201b5c | |||
| 5e10f012cb | |||
| 727768e083 | |||
| 0247206dfe | |||
| fbd3adabea | |||
| f778c613f1 | |||
| 7a418346a4 | |||
| a070cb563d | |||
| 685ed7e4a0 | |||
| dd99105d66 | |||
| 1fb5700662 | |||
| f1091676f8 | |||
| eb4762a2b8 | |||
| 4d2028fbef | |||
| d4c5e808c7 | |||
| 5299e18cd8 | |||
| 6a3439a49c | |||
| ba0b5a1fe0 | |||
| bfdb395731 | |||
| d1959cbe80 | |||
| a37bc7333e | |||
| 1a02971f88 | |||
| 5836c2ce5c | |||
| dae01f9c06 | |||
| e44164f5bd | |||
| 96c1d7baae | |||
| 8e88ae789f | |||
| e985151da9 | |||
| b393b7072c | |||
| 50ba4a64cb | |||
| 15d912ef34 | |||
| 2a4315cfe4 | |||
| b09f459aea | |||
| e2672c56d6 | |||
| b71a88a471 | |||
| e218d0f100 | |||
| b096e8ba2c | |||
| f6f92a8a6e | |||
| c03de9878d | |||
| ca584bda3f | |||
| cf3c99233b | |||
| 2bc813be13 | |||
| 9cfdfc9de8 | |||
| 772a494223 | |||
| 5608abdf5a | |||
| cf098a1d4f | |||
| a8e3633174 | |||
| ad016e4410 | |||
| 7a328679da | |||
| 580d5f5501 | |||
| 6c2039f5c9 | |||
| 6f8056d236 | |||
| 98365f3a55 | |||
| 8c9d96e065 | |||
| e9856c8f65 | |||
| 0af38a29c6 | |||
| b889934bba | |||
| 8cd884027f | |||
| e4be9667ec | |||
| 1b4643089c | |||
| 8c6b593a54 | |||
| 687e5655bc | |||
| e8d12db871 | |||
| aac88ce5ee | |||
| a7b54fef7f | |||
| 387b28ce09 | |||
| 775d9e8a2e | |||
| 968b01f26e | |||
| 6190a5a587 | |||
| 28fc833a6f | |||
| 74a24f93d7 | |||
| 2879e76883 | |||
| 98dcb2556c | |||
| f855893d1c | |||
| 5c214cac23 | |||
| 9b7b2675da | |||
| e2d04a31a7 | |||
| aa3ff5a12f | |||
| e272c686d3 | |||
| 4ef3371701 | |||
| a82267484a | |||
| 6bf34e051b | |||
| 4f31f942a5 | |||
| 3907347cac | |||
| 09071dc235 | |||
| 9b92a04230 | |||
| f91b27aedb | |||
| 525aa1f943 | |||
| 02cc74ce11 | |||
| 351e99dd70 | |||
| 12259ed4ec | |||
| 2a03a83ce0 | |||
| 670efcd426 | |||
| 62bdfa2b54 | |||
| 2572688c51 | |||
| 2ebdb9a5b7 | |||
| 1d7f011e5d | |||
| b20121f703 | |||
| d027c964e2 | |||
| 53a148cb54 | |||
| 5bbfcc02fb | |||
| 4f14d0947f | |||
| 11c1bbcebb | |||
| 82dcc86173 | |||
| d62430a71b | |||
| bfa95ba320 | |||
| 610e9d0bbb | |||
| fe2fdc9692 | |||
| 69a138c1af | |||
| 2e2602f437 | |||
| 9bb9cb3b1a | |||
| b74ee15321 | |||
| 8a1a7df406 | |||
| fa99f6b4eb | |||
| 304cad0648 | |||
| 48b5c2401c | |||
| eba300aec2 | |||
| a3a5cd4fee | |||
| 8510a2f2eb | |||
| 46e942f0c2 | |||
| 3de6b0426f | |||
| a0f7c9b5eb | |||
| 5fccc31afc | |||
| 2564e53342 | |||
| b902d61bb9 | |||
| 4def5e9ed4 | |||
| 14f3605f6e | |||
| c44ec0f210 | |||
| b777322b95 | |||
| f0cdbdb030 | |||
| d0fc7e03c1 | |||
| 5fb15f5045 | |||
| 14aa0c880b | |||
| a82b5b1626 | |||
| 2d5d246606 | |||
| 41c46d131e | |||
| 75447aa7f8 | |||
| ddef1956b5 | |||
| 6009ce801f | |||
| c431af0902 | |||
| bb0c274563 | |||
| c945eace1a | |||
| cb76828e43 | |||
| 534232b768 | |||
| 0afa84a13f | |||
| fad490dca0 | |||
| 168c88593d | |||
| ae42e486de | |||
| 5f126d4d10 | |||
| 51eb98e0ae | |||
| 8c5b35a6b1 | |||
| 79b7766ed4 | |||
| 061d2ae320 | |||
| 28a6d3f09c | |||
| 7d6c0bb90b | |||
| d1e502d367 | |||
| dc0ba690c6 | |||
| 22de000f23 | |||
| ece06b0375 | |||
| 121a2b9ace | |||
| 8157772132 | |||
| e1a94fc896 | |||
| ce53e8816b | |||
| 2ee007edc9 | |||
| ba5785036a | |||
| 5163ce51a7 | |||
| fb8a1b8ce3 | |||
| a024dc3f5f | |||
| a014f449f6 | |||
| 4dcd3623cb | |||
| b6c1ef15d2 | |||
| d88b9b2bc3 | |||
| f70ac92d24 | |||
| 0393daa644 | |||
| c0e92cceb1 | |||
| cacd9a8572 | |||
| cfe5a1a7f1 | |||
| 5b0cd45093 | |||
| f2e3215c77 | |||
| 8d368913a9 | |||
| 257ff0ad5d | |||
| c29abe9569 | |||
| 768c73889e |
31
.gitignore
vendored
31
.gitignore
vendored
@ -10,7 +10,6 @@ data/
|
||||
.tmp-plugins/
|
||||
.go/
|
||||
.local/
|
||||
internal/meta/
|
||||
|
||||
*.hmap
|
||||
dev/
|
||||
@ -24,9 +23,15 @@ cmd/gui/dist/
|
||||
.gopath/
|
||||
|
||||
# SDK 工具链 — 核心仓不追踪
|
||||
#
|
||||
# 外部插件与工具链维护在独立 SDK 仓(决策 sdk_repo_only),
|
||||
# 本仓经 go.mod 的 replace => ./third_party/homeagent-sdk 引用。
|
||||
# example/ 下已跟踪的 20 个文件(plg.json + plugin.go)早于本规则,
|
||||
# 靠「已跟踪文件不受 .gitignore 影响」保留——这是有意的,不要「修」。
|
||||
third_party/homeagent-sdk/bin/
|
||||
third_party/homeagent-sdk/tools/
|
||||
third_party/homeagent-sdk/package/
|
||||
third_party/homeagent-sdk/scripts/
|
||||
third_party/homeagent-sdk/.gitignore
|
||||
third_party/homeagent-sdk/README*
|
||||
third_party/homeagent-sdk/example/
|
||||
@ -36,12 +41,28 @@ third_party/homeagent-sdk/example/
|
||||
codegraph.json
|
||||
|
||||
# 运行时产物(不提交)
|
||||
adapters/
|
||||
knowledge/
|
||||
memory/
|
||||
scripts/
|
||||
#
|
||||
# ⚠️ 这些必须带前导斜杠。不带斜杠的模式(如 `memory/`)会匹配**任意层级**的
|
||||
# 同名目录,把 internal/memory/、internal/knowledge/、deploy/scripts/ 一起吞掉
|
||||
# ——24 + 3 + 1 个已跟踪源码文件曾因此落在 ignore 规则下,只靠「已跟踪
|
||||
# 文件不受 .gitignore 影响」这条 git 规则兜着,新增文件会默默不入库。
|
||||
/adapters/
|
||||
/knowledge/
|
||||
/memory/
|
||||
# 注:/scripts/ **不**忽略。它是作者维护的工具目录(模型导出、侧车、部署校验),
|
||||
# 不是运行期产物:deploy/systemd/embed-sidecar.service 直接引用
|
||||
# scripts/embed_sidecar.py,忽略它会让那份 unit 在别人的机器上指向不存在的文件。
|
||||
# 只忽略其中的缓存。
|
||||
/scripts/__pycache__/
|
||||
__pycache__/
|
||||
terminal_locked_log.txt
|
||||
|
||||
dist/
|
||||
.pi-glla/
|
||||
.omo/
|
||||
.pi/
|
||||
|
||||
# HarmonyOS 构建工具链缓存(HVIGOR_USER_HOME / ohpm 生成,非源码)
|
||||
.hvigor-home/
|
||||
.npm-cache/
|
||||
.ohpm/
|
||||
|
||||
46
.golangci.yml
Normal file
46
.golangci.yml
Normal file
@ -0,0 +1,46 @@
|
||||
# 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
Normal file
661
LICENSE
Normal file
@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
11
Makefile
11
Makefile
@ -61,3 +61,14 @@ fmt:
|
||||
|
||||
lint:
|
||||
$(GO) vet ./...
|
||||
|
||||
# lint-full:在 vet 之外跑 golangci-lint(阈值见 .golangci.yml,起步 warn-only)。
|
||||
# 未安装时给出可执行的安装提示与跳过原因,而不是静默成功。
|
||||
.PHONY: lint-full
|
||||
lint-full:
|
||||
@if command -v golangci-lint >/dev/null 2>&1; then \
|
||||
golangci-lint run; \
|
||||
else \
|
||||
echo "golangci-lint 未安装,跳过(阈值见 .golangci.yml)"; \
|
||||
echo " go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
|
||||
fi
|
||||
|
||||
102
README.md
102
README.md
@ -1,3 +1,5 @@
|
||||
> ⚠️ **AI 辅助编程声明**:本项目代码、文档及提交历史中,部分内容由 AI 辅助生成或修改。人工已审阅关键改动,但使用时请自行评估与验证。
|
||||
|
||||
# HomeAgent
|
||||
|
||||
> **English**: [README_EN.md](./README_EN.md)
|
||||
@ -10,6 +12,10 @@
|
||||
homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
||||
```
|
||||
|
||||
**v1.1.1 起媒体贯通插件边界**:插件与模型都能读写记忆里的图片/音频(`InsertWithMedia`、`InjectInputMedia`),媒体以 `[<mime> <短digest>] <描述>` 标记存在于纯文本记忆中——描述是可检索的语义记忆,digest 是回到字节的钥匙。
|
||||
|
||||
**v1.0.0 起外部插件是独立子进程**:经 stdio JSON-RPC(控制面)+ 共享内存段(数据面)+ 事件环(通知面)与内核通信。插件崩溃不影响内核且自动重启,换 `plugin.bin` 即生效的真热重载。
|
||||
|
||||
## 设计要点
|
||||
|
||||
**核心域与应用域分离** — 内核职责限定为 LLM 编排、记忆管理与知识检索;所有 IO 能力(消息收发、文件读写、网络请求、硬件交互等)由插件实现。这种划分在 Agent 框架层面进行领域边界界定,内核与插件各有其责任范围。
|
||||
@ -149,6 +155,23 @@ make build build-cli
|
||||
echo "你好,记住我喜欢喝咖啡" | ./build/waiter
|
||||
```
|
||||
|
||||
### 后台驻留模式(daemon)
|
||||
|
||||
waiter 也支持后台驻留,保持与 homed 的持久连接并等待 TUI 实例接入,适合让 agent 主动召唤用户/设备桥持续存活:
|
||||
|
||||
```bash
|
||||
# 后台驻留(默认连 ~/.homeagent/cli.sock)
|
||||
./build/waiter --daemon
|
||||
|
||||
# 指定 socket
|
||||
./build/waiter --socket /path/to/cli.sock --daemon
|
||||
|
||||
# 随后任意 TUI/一行实例都会自动接入正在运行的 daemon,而不是直连 homed
|
||||
./build/waiter
|
||||
```
|
||||
|
||||
daemon 监听 `~/.homeagent/waiter.sock`,新客户端连入时会回放缓冲的最近对话(256 行),断连后 daemon 持续存活、自动重连 homed,并保持设备桥(若配置了 `device_gateway`/`device_token`)。
|
||||
|
||||
API 密钥通过 WebUI `http://localhost:8080` 设置页配置,持久化在 SQLite 中。
|
||||
|
||||
## 代码结构
|
||||
@ -161,18 +184,51 @@ internal/
|
||||
├── agent/api/ LLM Provider + 8 个 Lua 适配器
|
||||
├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
|
||||
├── knowledge/ 知识库(文件系统 + TF-IDF)
|
||||
├── plugin/ 插件注册表 + .so 动态加载器
|
||||
├── plugin/ 插件注册表 + 子进程加载器(stdio RPC + 共享内存段 + 事件环)
|
||||
├── plugins/ 内置 11 个插件(webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
|
||||
├── sdk/ PluginSDK(Tool/Stage/Event 三通道)
|
||||
├── config/ SQLite 配置中心
|
||||
├── events/ 事件总线
|
||||
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
||||
```
|
||||
|
||||
## 项目状态
|
||||
|
||||
**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 转义全链路清洗)。
|
||||
**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.15GB);
|
||||
`qwen3vl` 保留(2048 维、常驻约 9.4GB,供内存充足或将来要视频的机器切回)。
|
||||
文本检索仍由既有词向量 / TF-IDF 兜底:CLIP 双塔的**纯文本语义弱于 MLLM 型嵌入器**,
|
||||
这是已知并写进文档的代价。
|
||||
- **媒体是图数据库的一等节点与边**:**彻底删除**「用文本描述式索引图片」这套将就机制,
|
||||
以及 `media_refs` 与媒体引用计数。记忆块遵循单层不变量——Context → Document → Graph
|
||||
是块的**迁移**,不是复制、也不靠引用保活。
|
||||
- **数据面全部走共享内存**(工具调用帧 / Cleaner / 输入输出通道 / 媒体块 / 文档与知识正文),
|
||||
RPC 只传偏移描述符;**RPC 协议升到 2**,fd3 布局改变,**不支持滚动升级**——
|
||||
内核与全部插件必须同批重建、同批安装,存量插件须用新版 `hmapdev` 重编。
|
||||
- 注入可声明 `InjectOptions{NoMemory, ContextPolicy}`(**默认仍记入记忆、默认不裁剪**);
|
||||
裁剪必须显式声明,且先经插件注册的 `Cleaner`。SDK 1.2.0 相对 1.1.0 **纯追加**。
|
||||
- **发行包默认启用** ONNX 向量空间,并把模型(754MB)与 ONNX Runtime(24MB)随
|
||||
server/full 包发布;`homed` 放弃 Windows 原生支持改走 WSL2;jieba 词库内嵌进二进制。
|
||||
- 修掉三个**安装链静默失败**:`initconfig` 因 `CGO_ENABLED=0` 是空操作(打印凭据却一个字节
|
||||
没写)、全新安装被误判「已有配置」而整体跳过默认值播种(装完 0 插件)、deb 的 `postinst`
|
||||
查错 unit 路径导致 `enable` 从未执行。
|
||||
- 自本版起以 **AGPL-3.0-only** 发布(含网络条款;插件静态链接 SDK 故须同许可,见「许可」)。
|
||||
|
||||
> 以下历史条目保留原文以呈现演进,其中两条机制**已在 v1.2.0 移除**:
|
||||
> 「媒体以 `[<mime> <短digest>] <描述>` 标记参与检索」(描述式索引)与「媒体引用计数式 GC」。
|
||||
|
||||
**v1.1.1** — 多模态贯通**插件边界**。v1.1.0 让记忆系统支持了二进制多媒体节点,但那条链路只对内核自己开放;本版打通到插件与模型。公开 SDK 新增媒体字段与三个媒体注入接口(配套 [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),整条 1.1.x 线共用),内核实现对应四个 RPC。桥接层此前在**静默裁字段**:插件交进来的 `Confidence`/类型/`SentenceText` 全被丢弃、`Doc` 只留三个字段、`Remove` 不解引用(媒体永久算「被引用」,GC 收不掉)。`processTextInput`/`processMediaInput` 归一成一条 `processInput`,媒体路径由此获得它一直缺的去重、`no_memory`、通道 `Cleaner`、中断语义、`EventRawInput`。修掉三处真实缺陷:**用户发的图从来没出现在 WebUI 聊天记录里**(媒体路径发布 map 而订阅方断言 string)、**`memory_commit` 的 `sentence_text` 从未暴露给模型**(而它是媒体绑定链的必经环节)、**`PluginSDK` 两处并发竞态**(`-race` 实测 11 处,插件重载瞬间偶发 nil 解引用崩溃)。
|
||||
|
||||
**v1.1.0** — 记忆系统支持**二进制多媒体节点**。内容寻址媒体存储(CAS + SQLite 元数据 + 磁盘 blob,`Get` always 重校 digest),贯通 L0(上下文事件)/L2(文档)/L3(图谱句子)三层,引用计数式 GC(有引用者绝不删)。视觉模型生成的描述文本是持久语义记忆,blob 只是可被容量 GC 淘汰的缓存。
|
||||
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版工具链重编;该工具链当时名为 `plugindev`,**现名 `hmapdev`**)。外部插件需重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
|
||||
**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.8.0** — 核心可用,插件系统增强。内置 20+ 插件,外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库。新增输入通道 `NoMemory`/`Cleaner`、`ChannelDef`、插件禁用/启用系统(CLI + WebUI),`plugindev` 工具链完成 C ABI `ChannelDef` 传递。
|
||||
|
||||
@ -184,6 +240,23 @@ internal/
|
||||
- [Lua Adapter](assets/docs/zh/ADAPTER.md) | [English](assets/docs/en/ADAPTER.md)
|
||||
- [知识库演示](assets/knowledge/homeagent_architecture/content.md)
|
||||
|
||||
## 下载
|
||||
|
||||
[Releases](https://gitcode.com/JianFeeeee/HomeAgent/releases) 提供三种变体:
|
||||
|
||||
| 变体 | 内容 | 适用 |
|
||||
|---|---|---|
|
||||
| **full** | homed + waiter + 桌面 GUI + systemd unit | 单机全功能 |
|
||||
| **server** | homed + waiter + systemd unit | 服务器(无桌面环境) |
|
||||
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
||||
|
||||
- Linux:`.deb`(amd64/arm64)、`.rpm`(x86_64)、`.tar.gz`
|
||||
- Windows:`HomeAgent_v1.2.0_{Full,Server,Client}_win64.exe`(NSIS 安装向导,含 AGPL 许可页)。自 v1.2.0 起因 `homed` 不再支持 Windows 原生(依赖 fd 继承与共享内存段内偏移解引用),安装器改为引导到 **WSL2**,并把 Linux 包送进发行版里按 Linux 方式安装。
|
||||
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
||||
- 校验:`SHA256SUMS`
|
||||
|
||||
macOS 的 `homed` 需在原生 macOS 构建(CGO + sqlite3),发布包仅含 `waiter`/`initconfig`。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
@ -193,3 +266,26 @@ make install # 安装到系统
|
||||
```
|
||||
|
||||
依赖:Go 1.25+, CGo (go-sqlite3), Linux/Windows。
|
||||
|
||||
## 许可
|
||||
|
||||
本项目以 **GNU Affero 通用公共许可证第 3 版(AGPL-3.0-only)** 发布,全文见 [LICENSE](LICENSE)。
|
||||
|
||||
它是 GPL 家族里**传染性最强**的一档:不仅分发时须提供完整对应源码,
|
||||
**通过网络提供服务时也要向使用者提供源码**(§13 Remote Network Interaction)。
|
||||
即:任何人把改过的 HomeAgent 对外提供网络服务,都必须让该服务的使用者拿到改动后的源码。
|
||||
|
||||
插件与本项目通过公开 SDK **静态链接**(SDK 源码会进入插件二进制),因此插件是本项目的
|
||||
衍生作品,需以相同许可发布;子进程隔离不改变这一点,因为被链接的是 SDK 代码本身。
|
||||
|
||||
### 随包分发的第三方组件
|
||||
|
||||
| 组件 | 许可 | 位置 |
|
||||
|---|---|---|
|
||||
| Chinese-CLIP ViT-B/16(ONNX 产物) | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
|
||||
| ONNX Runtime(`libonnxruntime.so`) | MIT | `/usr/lib/homeagent/onnxruntime/` |
|
||||
| jieba 词库(内嵌进二进制) | MIT | 源码 `internal/memory/jiebadict/` |
|
||||
| Go 依赖(go-sqlite3、gojieba、bubbletea 等) | MIT / BSD-3 / Apache-2.0 | 均为宽松许可,与 AGPL-3.0 兼容 |
|
||||
|
||||
这些组件**保持各自原有许可**,不在本项目的 AGPL 授权范围内;发行包把它们的许可全文放在
|
||||
`/usr/share/doc/homeagent/licenses/`,并在 dep/rpm 元数据里声明本包许可为 `AGPL-3.0-only`。
|
||||
|
||||
120
README_EN.md
120
README_EN.md
@ -1,3 +1,5 @@
|
||||
> ⚠️ **AI-Assisted Programming Notice**: Parts of this project's code, documentation, and commit history were generated or modified with AI assistance. Key changes have been human-reviewed, but please evaluate and verify before use.
|
||||
|
||||
# HomeAgent
|
||||
|
||||
> **中文**: [README.md](./README.md)
|
||||
@ -10,6 +12,16 @@ Combined with a **three-layer memory architecture** (Context → Document → Gr
|
||||
homed (kernel, zero IO) ← PluginSDK → plugins (all IO capabilities)
|
||||
```
|
||||
|
||||
**Since v1.1.1 media reaches the plugin boundary**: plugins and the model can both read and
|
||||
write images/audio in memory (`InsertWithMedia`, `InjectInputMedia`). Media lives in plain-text
|
||||
memory as a `[<mime> <short digest>] <description>` marker — the description is the searchable
|
||||
semantic memory, the digest is the key back to the bytes.
|
||||
|
||||
**Since v1.0.0 external plugins are independent subprocesses**, communicating with the kernel over
|
||||
stdio JSON-RPC (control plane) + a shared memory segment (data plane) + an event ring (notification
|
||||
plane). A plugin crash cannot take down the kernel and it restarts automatically; swapping
|
||||
`plugin.bin` gives true hot-reload.
|
||||
|
||||
## Design Principles
|
||||
|
||||
**Separation of Core Domain and Application Domain** — The kernel's responsibilities are limited to LLM orchestration, memory management, and knowledge retrieval; all IO capabilities (message send/receive, file read/write, network requests, hardware interaction, etc.) are implemented by plugins. This separation defines domain boundaries at the Agent framework level, with distinct responsibility scopes for the kernel and plugins.
|
||||
@ -161,18 +173,76 @@ internal/
|
||||
├── 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)
|
||||
├── knowledge/ Knowledge base (filesystem + TF-IDF)
|
||||
├── plugin/ Plugin registry + .so/.dll dynamic loader
|
||||
├── plugin/ Plugin registry + subprocess loader (stdio RPC + shared memory segment + event ring)
|
||||
├── plugins/ 11 built-in plugins (webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
|
||||
├── sdk/ PluginSDK (Tool/Stage/Event three channels)
|
||||
├── config/ SQLite config center
|
||||
├── events/ Event bus
|
||||
└── internal/lua/adapters/ 8 LLM protocol adapter scripts
|
||||
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `plugindev` toolchain, refer to Go and Lua examples in `example/`
|
||||
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `hmapdev` toolchain, refer to Go and Lua examples in `example/`
|
||||
```
|
||||
|
||||
## Project Status
|
||||
|
||||
**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).
|
||||
**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, ~1.15GB RSS measured standalone);
|
||||
`qwen3vl` is kept (2048-dim, ~9.4GB) for machines with headroom or future video. Text search
|
||||
still falls back to the existing word-vector / TF-IDF path — a CLIP dual tower's pure-text
|
||||
semantics are **weaker than an MLLM-style embedder**, a cost documented rather than hidden.
|
||||
- **Media are first-class nodes and edges in the graph DB**: the "index images via generated
|
||||
text descriptions" stopgap, `media_refs` and media reference counting are **removed**.
|
||||
Memory blocks follow a single-layer invariant — Context → Document → Graph is a **migration**,
|
||||
not a copy, and not kept alive by references.
|
||||
- **The entire data plane goes through shared memory** (tool-call frames, Cleaners, input/output
|
||||
lanes, media blocks, document and knowledge bodies); RPC carries only offset descriptors.
|
||||
**RPC protocol is now 2**: the fd3 layout changed and there is **no rolling upgrade** —
|
||||
kernel and all plugins must be rebuilt and installed together.
|
||||
- Injections can declare `InjectOptions{NoMemory, ContextPolicy}` (**defaults: still recorded,
|
||||
not pruned**); pruning must be requested explicitly and goes through the plugin's registered
|
||||
`Cleaner`. SDK 1.2.0 is **purely additive** over 1.1.0.
|
||||
- **Release packages enable the ONNX space by default** and bundle the model (754MB) plus
|
||||
ONNX Runtime (24MB) in the server/full packages; `homed` drops native Windows support in
|
||||
favour of WSL2; the jieba dictionary is embedded in the binary.
|
||||
- Fixed three **silent install-chain failures**: `initconfig` was a no-op (`CGO_ENABLED=0` stub)
|
||||
that printed credentials without writing any, fresh installs were misdetected as "already
|
||||
configured" so default seeding was skipped entirely (0 plugins installed), and the deb
|
||||
`postinst` looked for the unit in the wrong path so `enable` never ran.
|
||||
- Licensed **AGPL-3.0-only** from this version on (network clause included; statically linked
|
||||
plugins must match — see License).
|
||||
|
||||
> The historical entries below are kept verbatim to show the evolution; two mechanisms in them
|
||||
> were **removed in v1.2.0**: text-description-based media indexing, and reference-counted media GC.
|
||||
|
||||
**v1.1.1** — Multimodal reaches the **plugin boundary**. v1.1.0 gave the memory system binary
|
||||
multimedia nodes, but that path was open only to the kernel itself; this release opens it to
|
||||
plugins and the model. The public SDK gains media fields and three media injection methods
|
||||
(paired with [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),
|
||||
shared by the whole 1.1.x line), and the kernel implements the four matching RPCs. The bridge
|
||||
layer had been **silently dropping fields**: `Confidence`/types/`SentenceText` handed in by a
|
||||
plugin were discarded, `Doc` kept only three fields, and `Remove` never released references
|
||||
(media stayed "referenced" forever, so GC could never reclaim it). `processTextInput` and
|
||||
`processMediaInput` were unified into a single `processInput`, which finally gives the media
|
||||
path the dedup, `no_memory`, channel `Cleaner`, interrupt semantics and correct `EventRawInput`
|
||||
it had always lacked. Three real defects fixed: **user-sent images never appeared in the WebUI
|
||||
chat log** (the media path published a map while the subscriber asserted a string),
|
||||
**`memory_commit`'s `sentence_text` had never been exposed to the model** (though it is the
|
||||
mandatory link in the media binding chain), and **two data races in `PluginSDK`** (11 reported
|
||||
by `-race`; in production this showed up as sporadic nil-dereference crashes during plugin reload).
|
||||
|
||||
**v1.1.0** — Memory system supports **binary multimedia nodes**. Content-addressed media store
|
||||
(CAS + SQLite metadata + on-disk blobs, `Get` always re-verifies the digest) wired through L0
|
||||
(context events) / L2 (documents) / L3 (graph sentences), with reference-counted GC (referenced
|
||||
items are never deleted). The description text produced by the vision model is the durable
|
||||
semantic memory; the blob is only a cache that capacity GC may evict.
|
||||
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new toolchain — called `plugindev` back then, **now `hmapdev`** — though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
|
||||
**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.8.0** — Core is functional, plugin system enhanced. 20+ built-in plugins. External plugin development via [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo. Added input channel `NoMemory`/`Cleaner`, `ChannelDef`, plugin disable/enable system (CLI + WebUI), `plugindev` toolchain C ABI `ChannelDef` support.
|
||||
|
||||
@ -184,6 +254,23 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
||||
- [Lua Adapter](assets/docs/en/ADAPTER.md) | [中文](assets/docs/zh/ADAPTER.md)
|
||||
- [Knowledge Base Demo](assets/knowledge/homeagent_architecture/content.md)
|
||||
|
||||
## Downloads
|
||||
|
||||
[Releases](https://gitcode.com/JianFeeeee/HomeAgent/releases) ship three variants:
|
||||
|
||||
| Variant | Contents | For |
|
||||
|---|---|---|
|
||||
| **full** | homed + waiter + desktop GUI + systemd unit | Single-machine, everything |
|
||||
| **server** | homed + waiter + systemd unit | Servers (no desktop environment) |
|
||||
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
|
||||
|
||||
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
|
||||
- Windows: `HomeAgent_v1.2.0_{Full,Server,Client}_win64.exe` (NSIS installer, includes the AGPL license page). Since v1.2.0 `homed` no longer supports native Windows (it relies on fd inheritance and in-segment offset dereferencing), so the installer bootstraps **WSL2** and installs the Linux packages inside the distribution the same way a Linux host would.
|
||||
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
|
||||
- Verification: `SHA256SUMS`
|
||||
|
||||
The macOS `homed` requires a native macOS build (CGO + sqlite3), so release packages ship only `waiter`/`initconfig`.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
@ -193,3 +280,30 @@ make install # Install to system
|
||||
```
|
||||
|
||||
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux/Windows.
|
||||
|
||||
## License
|
||||
|
||||
This project is released under the **GNU Affero General Public License, version 3
|
||||
(AGPL-3.0-only)** — see [LICENSE](LICENSE).
|
||||
|
||||
This is the strongest copyleft in the GPL family: besides shipping the complete corresponding
|
||||
source when you distribute the software, **you must also offer the source to users who interact
|
||||
with it over a network** (§13, Remote Network Interaction). Anyone running a modified HomeAgent
|
||||
as a network service therefore has to make the modified source available to that service's users.
|
||||
|
||||
Plugins are **statically linked** against this project through the public SDK (the SDK source
|
||||
ends up inside the plugin binary), so plugins are derivative works and must be released under
|
||||
the same license. Process isolation does not change this — what is linked is the SDK code itself.
|
||||
|
||||
### Third-party components shipped with the packages
|
||||
|
||||
| Component | License | Location |
|
||||
|---|---|---|
|
||||
| Chinese-CLIP ViT-B/16 (ONNX artifacts) | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
|
||||
| ONNX Runtime (`libonnxruntime.so`) | MIT | `/usr/lib/homeagent/onnxruntime/` |
|
||||
| jieba dictionary (embedded in the binary) | MIT | `internal/memory/jiebadict/` |
|
||||
| Go dependencies (go-sqlite3, gojieba, bubbletea, …) | MIT / BSD-3 / Apache-2.0 | permissive, AGPL-3.0-compatible |
|
||||
|
||||
These components keep their own licenses and are not relicensed by this project. Full texts are
|
||||
shipped in `/usr/share/doc/homeagent/licenses/`, and the package metadata declares this package
|
||||
as `AGPL-3.0-only`.
|
||||
|
||||
@ -90,8 +90,8 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
RelevanceContext — In-memory events[] + JSON persistence
|
||||
Append: Each input, CleanTemplateText → three-branch vector(textForVector)
|
||||
agent→Response, user→Input, cold_storage→Input+Response
|
||||
StaticEmbedder pretrained word embedding / TF-IDF fallback
|
||||
Prune: StaticEmbedder CosineSimilarity, keep topK + last 10
|
||||
Vector layers: unified multimodal space (primary, with fingerprint) → StaticEmbedder word embedding → TF-IDF (fallback)
|
||||
Prune: DenseCosine (compared only within the same fingerprint) → StaticEmbedder CosineSimilarity fallback; keep topK + last 10
|
||||
├── Keep → timeline → chronologically sorted → system prompt
|
||||
└── Low score → Document layer archive (original timestamp)
|
||||
Save: 5s debounce write to disk
|
||||
@ -99,7 +99,7 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
↓ Prune archive ↑ LLM active recall
|
||||
|
||||
② Document (File Memory)
|
||||
DocStore — JSON files + shared StaticEmbedder vector space with Context (fallback: TF-IDF InvertedIndex)
|
||||
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)
|
||||
Write: Prune archive / doc_commit / Graph snapshot (syncGraphToDocs)
|
||||
Read:
|
||||
├── Auto-inject: Query(input, top3) → similarity summary under same vector space → [Related Memory Docs] → system prompt (read-only)
|
||||
@ -132,11 +132,22 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
→ triples → GraphDB.Commit
|
||||
```
|
||||
|
||||
### Vectorization: Pretrained Word Embedding + TF-IDF Fallback
|
||||
### Vectorization: Unified Multimodal Space (primary) → Word Embedding → TF-IDF (fallback)
|
||||
|
||||
All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedder.go`):
|
||||
Vectorization degrades through three layers by availability; **each missing layer reports an explicit
|
||||
error and never pretends to succeed**:
|
||||
|
||||
**Primary Strategy — Pretrained Word Embedding (aligned 300d)**
|
||||
**① Unified multimodal space (primary path, since v1.2.0)**
|
||||
Text and images share **one model, one dimension, one fingerprint** (default `chineseclip`: 512d,
|
||||
Apache-2.0, Chinese-native; `qwen3vl` or an external `http` provider are alternatives).
|
||||
Providers register through the public `pkg/embedding` SPI — **the kernel hardcodes no model**.
|
||||
Vectors persist together with their fingerprint (`dense_fp` / `vec_model`); any mismatch with the
|
||||
current fingerprint triggers recomputation, and only blocks with the **same fingerprint and the same
|
||||
dimension** participate in fusion (mixing coordinate systems yields a direction resembling neither).
|
||||
|
||||
**② Word embedding (text fallback)** — `StaticEmbedder` (`internal/memory/static_embedder.go`):
|
||||
|
||||
**Model sources** (aligned 300d)
|
||||
- Model sources: ConceptNet Numberbatch (77-language aligned) / fastText Chinese / fastText English
|
||||
- Configured via `core.agent.embedding_model_path` (comma-separated multi-model)
|
||||
- Path containing `numberbatch` → auto-download ConceptNet; `cc.zh.` → fastText Chinese; `cc.en.` → fastText English
|
||||
@ -189,6 +200,43 @@ All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedd
|
||||
| `doc_query` | Search from Document |
|
||||
| `doc_commit` | Write to Document |
|
||||
|
||||
Since v1.1.1 `memory_commit` and `doc_commit` accept `media_digests`, and the kernel appends the
|
||||
`[<mime> <short digest>] <description>` marker into the sentence/body — **the kernel builds the
|
||||
marker, the model only supplies the digest**. Requiring the caller to know the format would mean a
|
||||
single typo silently breaks reference binding with no error anywhere in the chain. `memory_commit`
|
||||
also gained `sentence_text`: media references hang off a sentence, so with no sentence there is
|
||||
nowhere to attach them.
|
||||
|
||||
### Media Memory (since v1.2.0: first-class memory blocks)
|
||||
|
||||
Media is not attached content but a **first-class memory node**: `internal/memory/media/` is a
|
||||
content-addressed store (CAS), and graph `block` nodes carry its digest plus its own vector, while
|
||||
structural edges (e.g. `sentence --contains--> block`) express ownership.
|
||||
|
||||
| Concern | Approach | Why |
|
||||
|---|---|---|
|
||||
| Addressing | sha256 digest; metadata in SQLite, blobs on disk (`blobs/<first2>/<rest>`, two-level fanout) | Identical bytes stored once; metadata must be queryable, blobs must not live in the database |
|
||||
| Integrity | Every `Get` re-verifies the digest | Silently returning corrupt data on disk damage is far worse than an error |
|
||||
| Write atomicity | `.tmp` + rename | A half-written file taken as complete content would permanently poison that digest |
|
||||
| Retrieval | Blocks carry **their own multimodal vector and fingerprint** and are searched directly | No description text is needed as an intermediary |
|
||||
| Lifecycle | **No separate GC, no refcounts, no keep-set**; deleting the block deletes the content | Media is a memory node, not a cache that needs keeping alive |
|
||||
|
||||
**Description-based indexing is gone**: the old implementation embedded a
|
||||
`[<mime> <short digest>] <description>` marker in the body and treated the description as the
|
||||
semantic memory (retrieval used it). That path was removed wholesale in v1.2.0: a description is
|
||||
second-hand model output, and retrieving "someone else's paraphrase of an image" is strictly worse
|
||||
than retrieving the image's own vector. Images are now retrieved only by their own vector in the
|
||||
unified space, and no media marker is written into the body.
|
||||
|
||||
**Cross-space vector migration**: media rows store their vector together with `vec_model` (the space
|
||||
fingerprint). At startup `reembedStaleMedia()` recomputes and **writes back** every row whose
|
||||
`vec_model` is empty (never embedded) or differs from the current space (model/dimension switched).
|
||||
Modalities outside the space return `ErrModalityUnsupported` — the kernel **never substitutes
|
||||
another model's vector**.
|
||||
|
||||
The media store is **optional throughout**: with `core.memory.media.enabled=false` or no
|
||||
configuration, the whole chain silently degrades to plain-text behaviour — no errors, no panics.
|
||||
|
||||
### Other Memory Layers
|
||||
|
||||
- **Social** (`internal/memory/social/social.go`) — Persona traits and relationship network, wraps GraphDB entity types
|
||||
@ -259,12 +307,18 @@ VM built-ins: `json.encode` / `json.decode` / `log` / `http_get` / `http_post`.
|
||||
| Method | Registration Mechanism | Compilation | Usage |
|
||||
|--------|----------------------|-------------|-------|
|
||||
| Built-in | `init()` → `RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/timer/mcp etc. |
|
||||
| External `.so` | C ABI dynamic loading | `-buildmode=c-shared` + bridge | qq/browser/files etc. |
|
||||
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `hmapdev build` → `plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
|
||||
| Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. |
|
||||
| SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter |
|
||||
|
||||
Built-in plugin registration: `internal/plugins/all.go` blank imports → each plugin `init()` → `Registry.Load()` scans directory to match factory.
|
||||
External plugin loading: `internal/plugin/dynamic.go` → copy to SHA256 temp path (bypass `plugin.Open` path cache) → `Open` + `Lookup("NewPlugin")`.
|
||||
|
||||
External plugin loading (since v1.0.0): `internal/plugin/dynamic_proc.go` → `exec.Command(plugin.bin)`
|
||||
→ inherit shared-segment fds → handshake (protocol version check) → `plugin.init` → `plugin.start`
|
||||
(the plugin reverse-registers tools/stages/channels during this window).
|
||||
**The C ABI channel (`-buildmode=c-shared` + bridge) was removed entirely in v1.0.0**—
|
||||
the old `plugin.Open` path-cache workarounds (SHA256 temp-path copies) retired with it.
|
||||
|
||||
Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter executes `main.lua` (at load time `sdk.register_*` only buffers handlers), then `Start()` swaps in the real SDK implementation and registers them in batch. The script is read only once at load time; runtime execution happens via callbacks.
|
||||
|
||||
### Built-in vs External Plugins
|
||||
@ -272,20 +326,39 @@ Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter exe
|
||||
| Dimension | Built-in Plugin | External Plugin |
|
||||
|-----------|----------------|-----------------|
|
||||
| Registration | `init()` calls `plugin.RegisterFactory(name, factory)` | Implements `NewPluginFactory(name, config) (sdk.Plugin, error)` entry function |
|
||||
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `plugindev build` to `.so`/`.dll` (`-buildmode=c-shared`), loaded via C ABI bridge |
|
||||
| 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 |
|
||||
| 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, etc.) |
|
||||
| Plugin directory | No separate directory, compiled into binary | `plugins/<name>/` independent directory with `plugin.json` + binary |
|
||||
| SDK permissions | Full PluginSDK (SocialAPI read/write, Publish events) | Restricted SDK (SocialAPI read-only, Subscribe-only events) |
|
||||
| Lifecycle | Starts/stops with kernel, no individual hot-reload | Independent Start/Stop, supports hot-reload (ReloadOne) and enable/disable |
|
||||
| Crash recovery | No independent recovery | Supports `SetAutoRestart(true)` for automatic crash restart |
|
||||
| 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` |
|
||||
| SDK permissions | Full PluginSDK (SocialAPI read/write, Publish events) | Narrowed `procCore` surface + manifest capabilities declaration + RPC boundary rejection |
|
||||
| Lifecycle | Starts/stops with kernel, no individual hot-reload | Independent process; true hot-reload by swapping `plugin.bin` (ReloadOne) plus enable/disable |
|
||||
| Crash recovery | No independent recovery | Process-level isolation: a crash cannot take down the kernel; the kernel detaches its registrations then restarts it with backoff (`SetAutoRestart(false)` opts out) |
|
||||
|
||||
Common ground:
|
||||
- Built-in `RegisterFactory` and external `NewPluginFactory` share the same `NativeFactory` type signature
|
||||
- `Registry.Load()` handles both uniformly: checks factory table first (built-in), falls back to dynamic loading (external)
|
||||
- Both use the same `Plugin` interface and `PluginSDK`; tool registration, stage hooks, and output channel APIs are identical
|
||||
- `Registry.Load()` handles both uniformly: checks the factory table first (built-in), otherwise dispatches by the manifest `entry` to the proc / lua / skill channel
|
||||
- Both use the same `Plugin` interface and public SDK API; tool registration, stage hooks, and output channel APIs are identical
|
||||
- Both share the same tool registry (`StageHost`); LLM invocations treat them identically
|
||||
|
||||
### The Three Communication Planes of Subprocess Plugins (v1.0.0)
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
**Subprocess lifecycle management**:
|
||||
- One dedicated `waitLoop` per subprocess (the sole `cmd.Wait()` call site)—it does not rely on
|
||||
stdout EOF, because grandchild processes forked by a plugin (browser spawning chromium,
|
||||
editdoc spawning python) inherit the same stdout, so EOF never arrives after the plugin itself dies
|
||||
- Central ledger `proc.Supervisor`: registered on successful handshake, unregistered on exit;
|
||||
`Host.Close()` runs StopAll before tearing down the segment (reversing that order leaves plugins
|
||||
holding a mapping that has been unmapped—SIGBUS on their next access)
|
||||
- Crash self-healing: detach registrations (tools + stage handlers + IO channels) → remove from
|
||||
the registry → restart with backoff
|
||||
- Linux `Pdeathsig` is the last-resort guard so subprocesses do not linger as orphans when homed is SIGKILLed
|
||||
|
||||
### PluginSDK Four Channels
|
||||
|
||||
```
|
||||
@ -309,8 +382,23 @@ sdk.Memory().Recall/Commit
|
||||
sdk.Knowledge().Search/Create
|
||||
sdk.Settings().Get/Set/List
|
||||
sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ channel, see output_send__qq_help for details", handler)
|
||||
|
||||
// v1.1.1 media APIs (all additive, no signature changes)
|
||||
sdk.DocMemory().InsertWithMedia(doc, attachments) // attachments with Data land in CAS; Digest-only ones reference existing content
|
||||
sdk.InjectInputMedia(source, channel, text, blocks) // media reaches the model in *this* turn
|
||||
sdk.InjectInputMediaSync(...) // same, and waits for the reply
|
||||
sdk.InjectInterruptMedia(...) // media-bearing interrupt, can preempt current processing
|
||||
```
|
||||
|
||||
How media injection differs from `SetToolBlocks`: the latter is only callable inside a tool handler
|
||||
and its media reaches the model with the **next** tool message; these three let a plugin
|
||||
**initiate a turn that carries media** — it goes out with this turn's message and is automatically
|
||||
stored in CAS with a memory reference attached. `Triple` and `Doc` gained `MediaDigests` /
|
||||
`Attachments` correspondingly.
|
||||
|
||||
`internal/sdk/` is the bridge implementation for this layer and is not subject to the public
|
||||
interface freeze (see `docs/git-branching.md` §6).
|
||||
|
||||
### Plugin Interface
|
||||
|
||||
```go
|
||||
|
||||
@ -25,6 +25,19 @@ The significance lies in clear responsibility boundaries: the kernel focuses on
|
||||
|
||||
Three progressive layers — context, cold archive, long-term graph memory — form an information decay and consolidation pipeline from short-term to persistent storage.
|
||||
|
||||
**Media Memory (since v1.1.0)** — Images and audio are not attachments; they are a kind of node in all three layers:
|
||||
- **Content-addressed store (CAS)**: addressed by digest, metadata in SQLite and blobs on disk, identical bytes
|
||||
stored once. Every `Get` re-verifies the digest (silently returning corrupt data is worse than an error).
|
||||
- **Reference-counted GC**: `owner_kind/owner_id/digest` is the primary key; context events, documents and graph
|
||||
sentences each hold their own references. **Referenced items are never deleted** — only unowned content past
|
||||
`minAge` is reclaimed.
|
||||
- **The description text is the durable semantic memory**: what the vision model produced is written into
|
||||
plain-text memory as a `[<mime> <short digest>] <description>` marker and participates in vector retrieval and
|
||||
distillation; the blob is only a cache that capacity GC may evict. Months later "that purple-blue-red
|
||||
three-band chart" is still findable — via the description, not the bytes.
|
||||
- **Reaches the plugin boundary since v1.1.1**: plugins read and write media through `InsertWithMedia` /
|
||||
`InjectInputMedia`; the model attaches media via the `media_digests` argument of `memory_commit` / `doc_commit`.
|
||||
|
||||
## What It Actually Does
|
||||
|
||||
Code is in the project root, implemented in Go.
|
||||
@ -50,7 +63,10 @@ Code is in the project root, implemented in Go.
|
||||
|
||||
**Plugin System** (`internal/plugin/`):
|
||||
- Built-in plugins: Go `init()` self-registration, compiled into kernel
|
||||
- External plugins: Go `-buildmode=c-shared` compiled to `.so`, dynamically loaded via C ABI bridge; also supports Lua script plugins
|
||||
- External plugins (since v1.0.0): compiled to an ordinary Go binary `plugin.bin`, spawned by the
|
||||
kernel as an **independent subprocess**, communicating over stdio JSON-RPC (control plane) +
|
||||
a shared memory segment (data plane) + an event ring (notification plane); Lua script plugins are
|
||||
also supported (the C ABI shared-library channel, `-buildmode=c-shared`, was removed entirely in v1.0.0)
|
||||
- PluginSDK (`internal/sdk/`) defines four channels: RegisterTool / RegisterStage / Subscribe / RegisterOutputChannel
|
||||
- 7 stage hooks: on_input → pre_action → post_action → before_toolcall → after_toolcall → before_output → after_output
|
||||
|
||||
|
||||
@ -29,75 +29,80 @@ type Plugin interface {
|
||||
|
||||
| Method | Use Case | Complexity |
|
||||
|--------|----------|------------|
|
||||
| **Dynamic .so/.dll plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain |
|
||||
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `hmapdev` toolchain |
|
||||
| **Built-in plugin** | Released with HomeAgent | Simple, requires merging into main repo |
|
||||
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `plugindev init --lua` |
|
||||
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `hmapdev init --lua` |
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
## 1. Quick Start: Using the plugindev Toolchain
|
||||
## 1. Quick Start: Using the hmapdev Toolchain
|
||||
|
||||
`plugindev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua plugin types.
|
||||
`hmapdev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua
|
||||
plugin types, and producing `.hmap` plugin bundles (the tool is named after that package format).
|
||||
|
||||
> Rename note: as of 1.2.0 the toolchain was renamed from `plugindev` to `hmapdev`; the SDK store moved from
|
||||
> `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk` (the old directory keeps working automatically).
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd homeagent-sdk/tools/plugindev
|
||||
go build -o plugindev
|
||||
# Add plugindev to PATH or use directly
|
||||
cd homeagent-sdk/tools/hmapdev
|
||||
go build -o hmapdev
|
||||
# Add hmapdev to PATH or use directly
|
||||
# Prebuilt binaries also ship as release assets (hmapdev_linux_amd64, ...)
|
||||
```
|
||||
|
||||
### SDK Version Management
|
||||
|
||||
`plugindev sdk` manages local SDK versions:
|
||||
`hmapdev sdk` manages local SDK versions:
|
||||
|
||||
```bash
|
||||
plugindev sdk list # list installed SDK versions
|
||||
plugindev sdk current # show current SDK version
|
||||
plugindev sdk latest # show latest available version
|
||||
plugindev sdk install v0.8.0 # install a specific version
|
||||
plugindev sdk use v0.8.0 # switch to a version
|
||||
plugindev sdk path # show current SDK path
|
||||
hmapdev sdk list # list installed SDK versions
|
||||
hmapdev sdk current # show current SDK version
|
||||
hmapdev sdk latest # show latest available version
|
||||
hmapdev sdk install v1.2.0 # install a specific version
|
||||
hmapdev sdk use v1.2.0 # switch to a version
|
||||
hmapdev sdk path # show current SDK path
|
||||
```
|
||||
|
||||
SDK is stored at `~/.homeagent/plugindev/sdk/<version>/`; `plugindev init` reads the current SDK version for `go.mod`.
|
||||
SDK is stored at `~/.homeagent/hmapdev/sdk/<version>/`; `hmapdev init` reads the current SDK version for `go.mod`.
|
||||
|
||||
### Source Debugging
|
||||
|
||||
`plugindev debug` interprets plugin source and prints a call trace, no compilation environment needed:
|
||||
`hmapdev debug` interprets plugin source and prints a call trace, no compilation environment needed:
|
||||
|
||||
```bash
|
||||
plugindev debug [dir] # dir defaults to the current directory
|
||||
hmapdev debug [dir] # dir defaults to the current directory
|
||||
```
|
||||
|
||||
### Creating a Go Plugin
|
||||
|
||||
```bash
|
||||
plugindev init myplugin
|
||||
hmapdev init myplugin
|
||||
cd myplugin
|
||||
# Edit plugin code
|
||||
vim plugin.go
|
||||
# Build and package (default is a multi-platform bundle, see below)
|
||||
plugindev build
|
||||
hmapdev build
|
||||
# Output: dist/myplugin_bundle.hmap
|
||||
# Single-platform build:
|
||||
plugindev build --no-bundle
|
||||
hmapdev build --no-bundle
|
||||
# Output: dist/myplugin_linux_amd64.hmap (or windows_amd64)
|
||||
```
|
||||
|
||||
### Creating a Lua Plugin
|
||||
|
||||
```bash
|
||||
plugindev init myluaplugin --lua
|
||||
hmapdev init myluaplugin --lua
|
||||
cd myluaplugin
|
||||
# Edit plugin code
|
||||
vim main.lua
|
||||
# Local test
|
||||
lua main.lua
|
||||
# Build and package
|
||||
plugindev build
|
||||
hmapdev build
|
||||
# Output: dist/myluaplugin_lua.hmap
|
||||
```
|
||||
|
||||
@ -114,7 +119,7 @@ myplugin/
|
||||
└── thirdpart/ — Optional external source code directory
|
||||
```
|
||||
|
||||
C ABI bridge files (`z_bridge_gen.go` + `z_entry.c`) are auto-generated at build time.
|
||||
Subprocess runtime files (`z_proc_gen.go` and friends) are auto-generated at build time.
|
||||
|
||||
**Lua plugin**:
|
||||
|
||||
@ -128,22 +133,22 @@ myluaplugin/
|
||||
|
||||
### Build & Package
|
||||
|
||||
`plugindev build` automatically handles compilation and packaging:
|
||||
`hmapdev build` automatically handles compilation and packaging:
|
||||
|
||||
```bash
|
||||
cd myplugin
|
||||
plugindev build # default bundle mode (multi-platform)
|
||||
plugindev build --no-bundle # single-target build (per plg.json targets)
|
||||
plugindev build --target linux/amd64 # append a target on top of plg.json targets
|
||||
plugindev build --outdir dist # output directory (default: dist)
|
||||
plugindev build --sdk-path <path> # SDK path override (go.mod replace)
|
||||
plugindev build --replace <mod@path> # append a go.mod replace directive (repeatable)
|
||||
hmapdev build # default bundle mode (multi-platform)
|
||||
hmapdev build --no-bundle # single-target build (per plg.json targets)
|
||||
hmapdev build --target linux/amd64 # append a target on top of plg.json targets
|
||||
hmapdev build --outdir dist # output directory (default: dist)
|
||||
hmapdev build --sdk-path <path> # SDK path override (go.mod replace)
|
||||
hmapdev build --replace <mod@path> # append a go.mod replace directive (repeatable)
|
||||
```
|
||||
|
||||
Execution process:
|
||||
1. Reads `plg.json` `targets`/`bundle` fields to determine build targets (bundle takes priority, see below)
|
||||
2. Auto-generates C ABI bridge code (`z_bridge_gen.go` + `z_entry.c`; Windows only `z_bridge_gen.go`)
|
||||
3. **Go plugin**: Runs `go build -buildmode=c-shared` (produces `.so` / `.dylib` / `.dll`)
|
||||
2. Auto-generates subprocess runtime code (`z_proc_gen.go` + `z_proc_shm_unix.go` + `z_proc_shm_windows.go`)
|
||||
3. **Go plugin**: Runs `go build` (a plain executable, `CGO_ENABLED=0`)
|
||||
4. **Lua plugin**: Packages source code directly, no compilation needed (contents: `plugin.json` + `main.lua`, plus optional `README.md`, `LICENSE`, `thirdpart/*.lua`)
|
||||
5. Generates `plugin.json` output manifest
|
||||
6. Packages as `.hmap` distribution (zip format, containing `plugin.json` + binary)
|
||||
@ -155,22 +160,37 @@ Execution process:
|
||||
| `plg.json` | Project metadata, maintained by developer | `targets` — single-target build list (e.g. `"linux/amd64,windows/amd64"`); `bundle` — multi-platform bundle switch (default `true`) |
|
||||
| `plugin.json` | Build artifact manifest, auto-generated | `entry` — entry filename; `platforms` — declared platforms |
|
||||
|
||||
Each target produces a separate `.hmap`; binary name by platform:
|
||||
Each target produces a separate `.hmap`. Subprocess plugins are plain executables with
|
||||
**no platform-specific extension**:
|
||||
|
||||
| Platform | Binary |
|
||||
|----------|--------|
|
||||
| Linux | `plugin.so` |
|
||||
| macOS | `plugin.dylib` |
|
||||
| Windows | `plugin.dll` |
|
||||
| Linux / macOS / Windows | `plugin.bin` |
|
||||
|
||||
Inside a bundle package the per-platform entries are named `plugin.bin.<goos>.<goarch>`;
|
||||
the kernel picks the one matching the current platform and renames it to `plugin.bin`.
|
||||
|
||||
> ⚠️ **v1.0.0 breaking change**: external plugins moved from C ABI shared libraries to
|
||||
> **subprocess + shared memory**.
|
||||
>
|
||||
> - `plugin.so` / `plugin.dylib` / `plugin.dll` are **no longer loaded**. The new kernel
|
||||
> skips legacy artifacts with an actionable error instead of crashing.
|
||||
> - **Business code needs no changes** — the public SDK interface is unchanged; just
|
||||
> rebuild with the new `hmapdev` (formerly `plugindev`).
|
||||
> - The `entry` field in `plg.json` is **meaningless for Go plugins** now (leaving
|
||||
> `plugin.so` there is harmless); it only distinguishes Lua plugins.
|
||||
> - Artifacts no longer need cgo, so cross-compiling requires no target C toolchain.
|
||||
> - Windows went from "only 3 stage fields delivered, no writeback" to all 16 fields
|
||||
> visible plus writeback, sharing the same RPC implementation as Unix.
|
||||
|
||||
### Build Targets & Multi-platform Bundle
|
||||
|
||||
**`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.
|
||||
**`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.
|
||||
|
||||
```bash
|
||||
plugindev build # default bundle, outputs dist/myplugin_bundle.hmap
|
||||
plugindev build --bundle # explicitly enable bundle (same as above)
|
||||
plugindev build --no-bundle # disable bundle, build per plg.json targets
|
||||
hmapdev build # default bundle, outputs dist/myplugin_bundle.hmap
|
||||
hmapdev build --bundle # explicitly enable bundle (same as above)
|
||||
hmapdev build --no-bundle # disable bundle, build per plg.json targets
|
||||
```
|
||||
|
||||
Notes:
|
||||
@ -260,7 +280,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
|
||||
### Entry Point
|
||||
|
||||
`plugindev init` generates `plugin.go` with the `NewPlugin` export function directly,
|
||||
`hmapdev init` generates `plugin.go` with the `NewPlugin` export function directly,
|
||||
which is the entry point when the kernel loads the plugin:
|
||||
|
||||
```go
|
||||
@ -269,8 +289,11 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
}
|
||||
```
|
||||
|
||||
At build time, `plugindev build` auto-generates C ABI bridge code (`z_bridge_gen.go` + `z_entry.c`),
|
||||
shared by both Windows DLL and Linux/macOS .so builds. No manual bridge code needed.
|
||||
At build time, `hmapdev build` auto-generates subprocess runtime code
|
||||
(`z_proc_gen.go` for the platform-independent part, plus `z_proc_shm_unix.go` /
|
||||
`z_proc_shm_windows.go`). All three platforms share the same entry point and the same
|
||||
RPC logic; only the cross-process resource-passing mechanism differs (inherited fds on
|
||||
Unix, named kernel objects on Windows). No manual bridge code needed.
|
||||
|
||||
### PluginSDK Core API
|
||||
|
||||
@ -322,7 +345,7 @@ Tool output → valuable for LLM attention?
|
||||
└── No → Normal memory, no extra handling
|
||||
```
|
||||
|
||||
> **Note**: `Cleaner` is a Go `func` type (`json:"-"`), cannot cross C ABI boundaries, so it is unavailable for C/C++/Rust remote plugins. **Lua plugins are not affected**: pass a Lua function in the def table (`cleaner = function(text) return text end`) — the Go bridge calls it back per invocation during memory computation.
|
||||
> **Note**: `Cleaner` is a Go `func` type (`json:"-"`), cannot be serialized across process boundaries, so it is unavailable for C/C++/Rust remote plugins. **Lua plugins are not affected**: pass a Lua function in the def table (`cleaner = function(text) return text end`) — the Go bridge calls it back per invocation during memory computation.
|
||||
|
||||
#### Stage Hooks — Intervene in message processing flow
|
||||
|
||||
@ -527,7 +550,7 @@ Lua plugins run inside the kernel process on a gopher-lua interpreter (single Lu
|
||||
|
||||
- **Passive callback model**: `main.lua` executes only once at load time. Afterward, tools, stage hooks, output/input channels, and registered APIs are all invoked by the kernel via callbacks into Lua functions. Plugins cannot start background tasks on their own.
|
||||
- **No concurrency / no long-running services**: Lua has no goroutines, coroutine scheduling, `os`/`io` libraries, or socket listening. The only outbound capability is `sdk.http.get/post` (synchronous). Any blocking loop will stall every call of that plugin while holding the lock.
|
||||
- **For long-running services (listening on a port, background polling, timers) use a Go plugin** (`.so`/`.dll` built with the toolchain, which may spawn goroutines — see the webui/cli plugins). The Lua equivalent is event-driven: register tools/stage hooks/channels to be called back by the kernel, or interact with external processes via `sdk.http`.
|
||||
- **For long-running services (listening on a port, background polling, timers) use a Go plugin** (`plugin.bin` built with the toolchain, which may spawn goroutines — see the webui/cli plugins). The Lua equivalent is event-driven: register tools/stage hooks/channels to be called back by the kernel, or interact with external processes via `sdk.http`.
|
||||
|
||||
### Plugin Structure
|
||||
|
||||
@ -576,25 +599,28 @@ When running inside the kernel, `sdk.*` global variables are injected by the Go
|
||||
|
||||
### Lua SDK API
|
||||
|
||||
The `sdk.*` API of Lua plugins is fully aligned with external plugins (C ABI / toolchain-built `.so`/`.dll`): 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 aligned with external plugins (toolchain-built `plugin.bin` subprocesses) up to **SDK 1.3.0** (requires kernel **1.4.0+**, also backfilled by the Lua-alignment patch `v1.3.11`): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors.
|
||||
|
||||
> Historical note: the 1.1–1.3 media / inject-flags / priority capabilities were long available only on the Go side and were silently missing on the Lua side. They are now fully aligned, guarded by the contract test in `internal/plugin/lua_surface_test.go` (every function promised by the mock has a runtime binding).
|
||||
|
||||
**Registration**
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `sdk.log(level, msg)` | Log output |
|
||||
| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `cleaner` |
|
||||
| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `context_policy` (`"none"`/`"prune"`), `cleaner` |
|
||||
| `sdk.register_stage(stage, handler, scope)` | Register stage hook; `scope` is `nil`/`"global"` (default) or `"own_tools"` (fires only for `before_toolcall`/`after_toolcall` when the tool belongs to this plugin) |
|
||||
| `sdk.register_api(name)` | Register API |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `cleaner` |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `context_policy`, `cleaner` |
|
||||
| `sdk.register_input_channel(name, def)` | Register input channel; `def` as above |
|
||||
| `sdk.unregister_output_channel(name)` | Unregister an output channel (for resource-bound channels, e.g. remote devices); returns `(nil, err)` |
|
||||
| `sdk.set_auto_restart(enabled)` | Auto-restart the plugin after a crash |
|
||||
|
||||
**Stage hook context**
|
||||
|
||||
Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `final_text`, `no_memory`, `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`, `reasoning_content`, `final_text`, `no_memory`, `context_msgs`, `token_usage`, `memory`, `extra`, `errors`, `response` (when responded), `tool_calls`, `tool_results`.
|
||||
|
||||
**Stage writeback (ABI v2)**: the `ctx` table passed to the handler is a reference — mutating writable fields inside the handler syncs back to the core `StageContext` (aligned with the C ABI v2 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):
|
||||
|
||||
```lua
|
||||
sdk.register_stage("on_input", function(ctx)
|
||||
@ -621,20 +647,37 @@ Writable fields: `raw_message`, `llm_text`, `final_text`, `user_id`, `group_id`,
|
||||
| `sdk.inject_text(source, channel, text)` | Deliver text message |
|
||||
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
|
||||
| `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation |
|
||||
| `sdk.inject_text_opts` / `sdk.inject_interrupt_opts(source, channel, text, opts)` | Delivery with flags; `opts = { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }` |
|
||||
| `sdk.inject_input_sync(source, channel, text)` | ⚠️ **Unavailable in Lua**: always returns `(nil, err)`. It waits for this turn's reply while a Lua callback holds the plugin lock, so it would self-deadlock. Use a Go plugin for synchronous waits, or the async injectors below |
|
||||
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | Same (unavailable) |
|
||||
| `sdk.inject_input_media(source, channel, text, blocks)` | Inject text + multimodal content blocks |
|
||||
| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | Same, with flags |
|
||||
| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | ⚠️ **Unavailable in Lua** (same as `inject_input_sync`) |
|
||||
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | Interrupt delivery with media |
|
||||
| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | Same, with flags |
|
||||
| `sdk.set_tool_blocks(blocks)` | Set multimodal blocks carried by the next tool message (lets the model see images / hear audio) |
|
||||
|
||||
**Data APIs (aligned with C ABI, all return `(result, err)`)**
|
||||
Each `blocks` item: `{ type="text", text="..." }`, `{ type="image_url", image_url={ url="...", detail="high" } }`, or `{ type="audio_url", audio_url={ url="..." } }`. An absent `opts` is the zero value (recorded in memory + no pruning), equivalent to the three-argument form.
|
||||
|
||||
**Data APIs (aligned with subprocess external plugins, all return `(result, err)`)**
|
||||
|
||||
| Sub-table | Functions |
|
||||
|-----------|-----------|
|
||||
| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})`, `introspect()`, `merge(source, target)`, `purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `remove(id)`, `stats()` |
|
||||
| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})` (triple supports `subject/relation/object/confidence/subject_type/object_type/sentence_text/media_digests`), `introspect()`, `merge(source, target)`, `purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `insert_with_media(doc, attachments)`, `remove(id)`, `stats()` |
|
||||
| `sdk.knowledge.*` | `search(query, limit)`, `add(tag, content)`, `list()` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` |
|
||||
| `sdk.llm.*` | `list_sources()`, `set_source(name)`, `current_source()` |
|
||||
| `sdk.social.*` (read-only) | `get_person(name)`, `get_network(name, depth)`, `get_trait(name, trait)`, `get_relations(name)`, `list_persons()` |
|
||||
| `sdk.events.*` | `subscribe(event_type, handler)` → returns an unsubscribe function; handler receives `{type,source,timestamp,payload}` |
|
||||
| `sdk.plugin_mgr.*` | `reload_one(name)`, `list_loaded()`, `is_disabled(name)` |
|
||||
| `sdk.json.*` | `encode(val)`, `decode(str)` |
|
||||
| `sdk.http.*` | `get(url)`, `post(url, body, content_type)` |
|
||||
|
||||
Each `attachments` item: `{ digest=, mime=, name=, data=<base64> }`; with `data` it is new content (stored in the content-addressed store), with only `digest` it references existing content.
|
||||
|
||||
> The `sdk.events.subscribe` callback runs on the kernel's event-publishing goroutine, and Lua is single-state + mutex-guarded — **do only lightweight forwarding inside the callback; never block**, or every call of this plugin will stall.
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
@ -90,8 +90,8 @@ eventLoop() → processTextInput()
|
||||
RelevanceContext — 内存 events[] + JSON持久化
|
||||
Append: 每次输入, CleanText → 三分支向量(textForVector)
|
||||
agent事件→Response, 用户事件→Input, cold_storage→Input+Response
|
||||
StaticEmbedder 预训练词嵌入 / TF-IDF 回退
|
||||
Prune: StaticEmbedder CosineSimilarity, 保留 topK + 最近10条
|
||||
向量层级:统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退)
|
||||
Prune: DenseCosine(仅同指纹才比较)→ 退化 StaticEmbedder CosineSimilarity;保留 topK + 最近10条
|
||||
├── 保留 → timeline → 按时间排序 → system prompt
|
||||
└── 低分 → Document 层归档 (原始时间戳)
|
||||
Save: 5s debounce 写盘
|
||||
@ -99,7 +99,7 @@ eventLoop() → processTextInput()
|
||||
↓ Prune 归档 ↑ LLM 主动召回
|
||||
|
||||
② Document (文件记忆)
|
||||
DocStore — JSON文件 + 与 Context 共享的 StaticEmbedder 向量空间(兜底: TF-IDF InvertedIndex)
|
||||
DocStore — JSON文件 + 稠密向量(统一多模态空间;dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex)
|
||||
写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs)
|
||||
读取:
|
||||
├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读)
|
||||
@ -132,11 +132,20 @@ eventLoop() → processTextInput()
|
||||
→ 三元组 → GraphDB.Commit
|
||||
```
|
||||
|
||||
### 向量化:预训练词嵌入 + TF-IDF 回退
|
||||
### 向量化:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退)
|
||||
|
||||
所有向量化统一使用 `StaticEmbedder`(`internal/memory/static_embedder.go`):
|
||||
向量化按可用性分三层降级,**每一层缺位都明确报错,不静默假装成功**:
|
||||
|
||||
**主策略 — 预训练词嵌入(词对齐 300 维)**
|
||||
**① 统一多模态空间(主路径,v1.2.0 起)**
|
||||
文本与图像共用**同一模型、同一维度、同一指纹**(默认 `chineseclip`:512 维、Apache-2.0、中文原生;
|
||||
亦可选 `qwen3vl` 或外部 `http` provider)。provider 经 `pkg/embedding` 公共 SPI 注册,
|
||||
**内核不硬编码任何模型**。向量与指纹一起持久化(`dense_fp` / `vec_model`),
|
||||
与当前指纹不一致即触发重算;融合时只接受**同指纹且同维度**的块向量
|
||||
(跨坐标系的向量混进去会算出两边都不像的方向)。
|
||||
|
||||
**② 词嵌入(文本兜底)** — `StaticEmbedder`(`internal/memory/static_embedder.go`):
|
||||
|
||||
**模型来源**(词对齐 300 维)
|
||||
- 模型来源:ConceptNet Numberbatch(77 语对齐)/ fastText 中文 / fastText 英文
|
||||
- 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型)
|
||||
- 路径名含 `numberbatch` → 自动下载 ConceptNet,含 `cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文
|
||||
@ -189,6 +198,37 @@ eventLoop() → processTextInput()
|
||||
| `doc_query` | 从 Document 搜索 |
|
||||
| `doc_commit` | 写入 Document |
|
||||
|
||||
`memory_commit` 与 `doc_commit` 自 v1.1.1 起接受 `media_digests`,并由内核把
|
||||
`[<mime> <短digest>] <描述>` 标记补进句子/正文——**标记由内核拼,模型只给 digest**。
|
||||
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。
|
||||
`memory_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。
|
||||
|
||||
### 媒体记忆(v1.2.0 起:一等记忆块)
|
||||
|
||||
媒体不是外挂内容,而是**记忆的一等节点**:`internal/memory/media/` 是内容寻址仓储(CAS),
|
||||
图数据库里的 block 节点携带它的 digest 与向量,结构边(如 `sentence --contains--> block`)表达归属。
|
||||
|
||||
| 关注点 | 做法 | 为何 |
|
||||
|---|---|---|
|
||||
| 寻址 | sha256 digest;元数据在 SQLite,blob 在磁盘(`blobs/<前2位>/<其余>` 两级分桶) | 相同字节只存一份;元数据要可查询,blob 不该进数据库 |
|
||||
| 完整性 | 每次 `Get` 重校 digest | 磁盘损坏时静默返回脏数据比报错危险得多 |
|
||||
| 写入原子性 | `.tmp` + rename | 半个文件被当成完整内容会永久污染那个 digest |
|
||||
| 检索 | 块携带**自己的多模态向量与指纹**,直接参与向量检索 | 不需要描述文本做中介 |
|
||||
| 生命周期 | **无独立 GC、无引用计数、无 keep-set**;删除块即删内容 | 媒体是记忆节点,不是需要保活的缓存 |
|
||||
|
||||
**不再有描述式索引**:旧实现在正文里写 `[<mime> <短digest>] <描述>` 标记,并把描述文本当作语义记忆
|
||||
(检索靠描述)。该机制已在 v1.2.0 整体拆除:描述是模型生成的二手信息,
|
||||
检索“别人转述的图片”不如检索图片自己的向量。现在图片只按自己的统一空间向量被检索,
|
||||
正文里不再有 media marker。
|
||||
|
||||
**跨空间向量迁移**:媒体行的向量带 `vec_model`(空间指纹)。启动时
|
||||
`reembedStaleMedia()` 把 `vec_model` 为空(从未嵌入)或与当前空间不一致(换过模型/维度)的行
|
||||
批量重算并**写回库**;模态不在本空间覆盖范围时返回 `ErrModalityUnsupported`,
|
||||
**绝不拿别的模型的向量顶替**。
|
||||
|
||||
媒体存储全程可选:`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为纯文本行为,
|
||||
不报错不 panic。
|
||||
|
||||
### 其他记忆层
|
||||
|
||||
- **Social** (`internal/memory/social/social.go`) — 人格特质和关系网,包装 GraphDB 实体类型
|
||||
@ -257,12 +297,18 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
|
||||
| 方式 | 注册机制 | 编译 | 用途 |
|
||||
|------|----------|------|------|
|
||||
| 内置插件 | `init()` → `RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 |
|
||||
| 外部 `.so` | C ABI 动态加载 | `-buildmode=c-shared` + bridge | qq/browser/files 等 |
|
||||
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `hmapdev build` → `plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
|
||||
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
|
||||
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
|
||||
|
||||
内置插件注册:`internal/plugins/all.go` 空白导入 → 各插件 `init()` → `Registry.Load()` 扫描目录匹配工厂。
|
||||
外部插件加载:`internal/plugin/dynamic.go` → 复制到 SHA256 临时路径(绕过 `plugin.Open` 路径缓存)→ `Open` + `Lookup("NewPlugin")`。
|
||||
|
||||
外部插件加载(v1.0.0 起):`internal/plugin/dynamic_proc.go` → `exec.Command(plugin.bin)`
|
||||
→ 继承共享段 fd → 握手(比对 protocol 版本)→ `plugin.init` → `plugin.start`
|
||||
(插件在此期间反向注册工具/阶段/通道)。
|
||||
**C ABI 通道(`-buildmode=c-shared` + bridge)已在 v1.0.0 整体删除**——
|
||||
旧的 `plugin.Open` 路径缓存绕行、SHA256 临时路径复制等手法随之退场。
|
||||
|
||||
Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main.lua`(加载期 `sdk.register_*` 仅暂存 handler),`Start()` 时替换为真实 SDK 实现并批量注册。脚本只在加载时读取一次,运行期通过回调执行。
|
||||
|
||||
### 内置插件 vs 外部插件
|
||||
@ -270,20 +316,41 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
|
||||
| 维度 | 内置插件 | 外部插件 |
|
||||
|------|----------|----------|
|
||||
| 注册方式 | `init()` 调用 `plugin.RegisterFactory(name, factory)` | 实现 `NewPluginFactory(name, config) (sdk.Plugin, error)` 入口函数 |
|
||||
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `plugindev build` 编译为 `.so`/`.dll`(`-buildmode=c-shared`),C ABI bridge 加载 |
|
||||
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `hmapdev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo),内核 spawn 为子进程 |
|
||||
| 分发方式 | 随内核分发,不可独立安装/卸载 | `.hmap` 包(ZIP 归档),通过 WebUI 或 pluginmgr API 安装 |
|
||||
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件(name, version, entry, platforms 等) |
|
||||
| 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + 二进制 |
|
||||
| SDK 权限 | 完整 PluginSDK(SocialAPI 读写、Publish 事件) | 受限 SDK(SocialAPI 只读、仅 Subscribe 事件) |
|
||||
| 生命周期 | 随内核启动/停止,不可单独热重载 | 独立 Start/Stop,支持热重载(ReloadOne)和禁用/启用 |
|
||||
| 崩溃恢复 | 无独立恢复机制 | 支持 `SetAutoRestart(true)` 崩溃自动重启 |
|
||||
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件(name, version, entry, platforms, capabilities 等) |
|
||||
| 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + `plugin.bin` |
|
||||
| SDK 权限 | 完整 PluginSDK(SocialAPI 读写、Publish 事件) | 收窄的 `procCore` 能力面 + manifest capabilities 声明 + RPC 边界拒绝 |
|
||||
| 生命周期 | 随内核启动/停止,不可单独热重载 | 独立进程,换 `plugin.bin` 即生效的真热重载(ReloadOne)和禁用/启用 |
|
||||
| 崩溃恢复 | 无独立恢复机制 | 进程级隔离:崩溃不影响内核,内核摘除其注册面后按退避自动重启(`SetAutoRestart(false)` 可关) |
|
||||
|
||||
两者的联系:
|
||||
- 内置插件的工厂函数 `RegisterFactory` 与外部插件的 `NewPluginFactory` 共用同一个 `NativeFactory` 类型签名
|
||||
- `Registry.Load()` 统一处理两者的加载:先查工厂表(内置),无工厂则尝试动态加载(外部)
|
||||
- 两者使用相同的 `Plugin` 接口和 `PluginSDK`,工具注册、阶段钩子、输出通道等 API 完全一致
|
||||
- `Registry.Load()` 统一处理两者的加载:先查工厂表(内置),无工厂则按 manifest 的 `entry` 分派到 proc / lua / skill 通道
|
||||
- 两者使用相同的 `Plugin` 接口和公开 SDK API,工具注册、阶段钩子、输出通道等完全一致
|
||||
- 两者共享同一个工具注册表(`StageHost`),LLM 调用时无差别
|
||||
|
||||
### 子进程插件的三个通信面(v1.0.0)
|
||||
|
||||
| 面 | 机制 | 为何这么选 |
|
||||
|---|---|---|
|
||||
| 控制面 | stdio JSON-RPC(NDJSON 帧),55 个 `core.*` method | 进程边界即 ABI 边界,无需维护三套平台特定的动态库加载代码 |
|
||||
| 数据面 | 共享内存段,**全部子进程共用一块** | 每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型,lost update 原样复现 |
|
||||
| 通知面 | 事件环 + 平台通知(Linux eventfd / macOS pipe / Windows Event) | 内核发事件绕不等消费者,流式输出逐 token 发布时任何等待都会造成卡顿 |
|
||||
|
||||
v1.1.1 新增 4 个 method(51 → 55):`doc.insertWithMedia`、`io.injectMedia`、
|
||||
`io.injectMediaSync`、`io.injectInterruptMedia`。**媒体块走 JSON 而非共享段二进制通道**——
|
||||
data URL 本身已是 base64 文本,包进二进制传输省不了空间,还要跟其余 51 个 method 分道。
|
||||
|
||||
**子进程生命周期管理**:
|
||||
- 每子进程一根专职 `waitLoop`(`cmd.Wait()` 唯一调用点)——不依赖 stdout EOF,
|
||||
因为插件 fork 的孙子进程(browser 拉 chromium、editdoc 拉 python)继承同一 stdout,
|
||||
插件本体死后 EOF 永不到来
|
||||
- 集中台账 `proc.Supervisor`:握手成功即登记,退出即注销;`Host.Close()` 先 StopAll 再拆段
|
||||
(顺序反了插件还持有映射而段已 unmap,下次访问就是 SIGBUS)
|
||||
- 崩溃自愈:摘注册面(工具 + stage handler + IO 通道)→ 移出注册表 → 退避重启
|
||||
- Linux `Pdeathsig` 兜底 homed 被强杀时子进程不滞留为孤儿
|
||||
|
||||
### PluginSDK 四通道
|
||||
|
||||
```
|
||||
@ -307,8 +374,20 @@ sdk.Memory().Recall/Commit
|
||||
sdk.Knowledge().Search/Create
|
||||
sdk.Settings().Get/Set/List
|
||||
sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ消息通道,详见 output_send__qq_help", handler)
|
||||
|
||||
// v1.1.1 媒体接口(全部新增,无签名变更)
|
||||
sdk.DocMemory().InsertWithMedia(doc, attachments) // 带 Data 的落进 CAS,只给 Digest 的引用已有内容
|
||||
sdk.InjectInputMedia(source, channel, text, blocks) // 媒体在「本轮」就发给模型
|
||||
sdk.InjectInputMediaSync(...) // 同上并同步等回复
|
||||
sdk.InjectInterruptMedia(...) // 带媒体的中断,可抢占当前处理
|
||||
```
|
||||
|
||||
媒体注入与 `SetToolBlocks` 的区别:后者只能在工具处理函数内部调用,且媒体要等**下一条**
|
||||
tool message 才到模型手上;前三个是插件**主动发起一轮带媒体的对话**,媒体随本轮消息发出,
|
||||
并自动落进 CAS、挂上媒体记忆引用。`Triple` 与 `Doc` 相应新增 `MediaDigests`、`Attachments`。
|
||||
|
||||
`internal/sdk/` 是这层的桥接实现,不受公开接口冻结约束(见 `docs/git-branching.md` §六)。
|
||||
|
||||
### Plugin 接口
|
||||
|
||||
```go
|
||||
|
||||
@ -25,6 +25,17 @@ 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 语言实现。
|
||||
@ -50,7 +61,9 @@ HomeAgent 是一个持续运行的个人智能 Agent 框架。
|
||||
|
||||
**插件系统** (`internal/plugin/`):
|
||||
- 内置插件:Go `init()` 自注册,编译进内核
|
||||
- 外部插件:Go `-buildmode=c-shared` 编译为 `.so`,通过 C ABI bridge 动态加载;也支持 Lua 脚本插件
|
||||
- 外部插件(v1.0.0 起):编译为普通 Go 二进制 `plugin.bin`,内核 spawn 为**独立子进程**,
|
||||
经 stdio JSON-RPC(控制面)+ 共享内存段(数据面)+ 事件环(通知面)通信;也支持 Lua 脚本插件
|
||||
(C ABI 动态库通道 `-buildmode=c-shared` 已在 v1.0.0 整体删除)
|
||||
- PluginSDK (`internal/sdk/`) 定义四通道:RegisterTool / RegisterStage / Subscribe / RegisterOutputChannel
|
||||
- 阶段钩子 7 个:on_input → pre_action → post_action → before_toolcall → after_toolcall → before_output → after_output
|
||||
|
||||
|
||||
@ -30,75 +30,80 @@ type Plugin interface {
|
||||
|
||||
| 方式 | 适用场景 | 复杂度 |
|
||||
|------|---------|--------|
|
||||
| **动态 .so/.dll 插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
|
||||
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `hmapdev` 工具链生成 |
|
||||
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
|
||||
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 |
|
||||
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `hmapdev init --lua` 生成 |
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
## 一、快速开始:使用 plugindev 工具链
|
||||
## 一、快速开始:使用 hmapdev 工具链
|
||||
|
||||
`plugindev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型。
|
||||
`hmapdev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型,
|
||||
最终产出 `.hmap` 插件包(工具名即来自这个包格式)。
|
||||
|
||||
> 改名说明:1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`;SDK 存储目录同时由
|
||||
> `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`(旧目录会自动继续沿用)。
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
cd homeagent-sdk/tools/plugindev
|
||||
go build -o plugindev
|
||||
# 将 plugindev 加入 PATH 或直接使用
|
||||
cd homeagent-sdk/tools/hmapdev
|
||||
go build -o hmapdev
|
||||
# 将 hmapdev 加入 PATH 或直接使用
|
||||
# 也可从 SDK 的 release 附件下载预编译二进制(hmapdev_linux_amd64 等)
|
||||
```
|
||||
|
||||
### SDK 版本管理
|
||||
|
||||
`plugindev sdk` 子命令管理本地 SDK 版本:
|
||||
`hmapdev sdk` 子命令管理本地 SDK 版本:
|
||||
|
||||
```bash
|
||||
plugindev sdk list # 列出已安装的 SDK 版本
|
||||
plugindev sdk current # 显示当前使用的 SDK 版本
|
||||
plugindev sdk latest # 显示最新可用版本
|
||||
plugindev sdk install v0.8.0 # 安装指定版本
|
||||
plugindev sdk use v0.8.0 # 切换使用版本
|
||||
plugindev sdk path # 显示当前 SDK 路径
|
||||
hmapdev sdk list # 列出已安装的 SDK 版本
|
||||
hmapdev sdk current # 显示当前使用的 SDK 版本
|
||||
hmapdev sdk latest # 显示最新可用版本
|
||||
hmapdev sdk install v1.2.0 # 安装指定版本
|
||||
hmapdev sdk use v1.2.0 # 切换使用版本
|
||||
hmapdev sdk path # 显示当前 SDK 路径
|
||||
```
|
||||
|
||||
SDK 存储在 `~/.homeagent/plugindev/sdk/<version>/`,`plugindev init` 自动读取当前 SDK 版本填充 `go.mod`。
|
||||
SDK 存储在 `~/.homeagent/hmapdev/sdk/<version>/`,`hmapdev init` 自动读取当前 SDK 版本填充 `go.mod`。
|
||||
|
||||
### 源码调试
|
||||
|
||||
`plugindev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
|
||||
`hmapdev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
|
||||
|
||||
```bash
|
||||
plugindev debug [dir] # dir 默认当前目录
|
||||
hmapdev debug [dir] # dir 默认当前目录
|
||||
```
|
||||
|
||||
### 创建 Go 插件
|
||||
|
||||
```bash
|
||||
plugindev init myplugin
|
||||
hmapdev init myplugin
|
||||
cd myplugin
|
||||
# 编辑插件代码
|
||||
vim plugin.go
|
||||
# 编译打包
|
||||
plugindev build # 默认多平台 bundle(见下节)
|
||||
hmapdev build # 默认多平台 bundle(见下节)
|
||||
# 输出: dist/myplugin_bundle.hmap
|
||||
# 单平台构建:
|
||||
plugindev build --no-bundle
|
||||
hmapdev build --no-bundle
|
||||
# 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64)
|
||||
```
|
||||
|
||||
### 创建 Lua 插件
|
||||
|
||||
```bash
|
||||
plugindev init myluaplugin --lua
|
||||
hmapdev init myluaplugin --lua
|
||||
cd myluaplugin
|
||||
# 编辑插件代码
|
||||
vim main.lua
|
||||
# 本地测试
|
||||
lua main.lua
|
||||
# 编译打包
|
||||
plugindev build
|
||||
hmapdev build
|
||||
# 输出: dist/myluaplugin_lua.hmap
|
||||
```
|
||||
|
||||
@ -115,7 +120,7 @@ myplugin/
|
||||
└── thirdpart/ — 外部源码存放目录(可选)
|
||||
```
|
||||
|
||||
编译时自动生成 C ABI bridge 文件(`z_bridge_gen.go` + `z_entry.c`),无需手动创建。
|
||||
编译时自动生成子进程运行时文件(`z_proc_gen.go` 等),无需手动创建。
|
||||
|
||||
**Lua 插件**:
|
||||
|
||||
@ -129,22 +134,22 @@ myluaplugin/
|
||||
|
||||
### 编译打包
|
||||
|
||||
`plugindev build` 会自动完成编译和打包:
|
||||
`hmapdev build` 会自动完成编译和打包:
|
||||
|
||||
```bash
|
||||
cd myplugin
|
||||
plugindev build # 默认 bundle 模式(多平台合集)
|
||||
plugindev build --no-bundle # 单平台构建(仅当前 plg.json targets)
|
||||
plugindev build --target linux/amd64 # 在 targets 基础上追加一个目标
|
||||
plugindev build --outdir dist # 指定输出目录(默认 dist)
|
||||
plugindev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace)
|
||||
plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
|
||||
hmapdev build # 默认 bundle 模式(多平台合集)
|
||||
hmapdev build --no-bundle # 单平台构建(仅当前 plg.json targets)
|
||||
hmapdev build --target linux/amd64 # 在 targets 基础上追加一个目标
|
||||
hmapdev build --outdir dist # 指定输出目录(默认 dist)
|
||||
hmapdev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace)
|
||||
hmapdev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
|
||||
```
|
||||
|
||||
执行过程:
|
||||
1. 读取 `plg.json` 的 `targets`/`bundle` 字段确定构建目标(bundle 模式优先,见下节)
|
||||
2. 自动生成 C ABI bridge 代码(`z_bridge_gen.go` + `z_entry.c`,Windows 仅 `z_bridge_gen.go`)
|
||||
3. **Go 插件**:执行 `go build -buildmode=c-shared`(生成 `.so` / `.dylib` / `.dll`)
|
||||
2. 自动生成子进程运行时代码(`z_proc_gen.go` + `z_proc_shm_unix.go` + `z_proc_shm_windows.go`)
|
||||
3. **Go 插件**:执行 `go build`(普通可执行文件,`CGO_ENABLED=0`)
|
||||
4. **Lua 插件**:直接打包源码,无需编译(打包内容:`plugin.json` + `main.lua`,以及可选的 `README.md`、`LICENSE`、`thirdpart/*.lua`)
|
||||
5. 生成 `plugin.json` 输出清单
|
||||
6. 打包为 `.hmap` 分发包(zip 格式,内含 `plugin.json` + 二进制)
|
||||
@ -154,24 +159,36 @@ plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次
|
||||
| 文件 | 用途 | 关键字段 |
|
||||
|------|------|---------|
|
||||
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"`);`bundle` — 多平台合集开关(默认 `true`)|
|
||||
| `plugin.json` | 构建产物清单,`plugindev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
|
||||
| `plugin.json` | 构建产物清单,`hmapdev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
|
||||
|
||||
每个目标生成单独的 `.hmap`,二进制文件名由平台决定:
|
||||
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**:
|
||||
|
||||
| 平台 | 二进制 |
|
||||
|------|--------|
|
||||
| Linux | `plugin.so` |
|
||||
| macOS | `plugin.dylib` |
|
||||
| Windows | `plugin.dll` |
|
||||
| Linux / macOS / Windows | `plugin.bin` |
|
||||
|
||||
bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核挑当前平台
|
||||
那份重命名为 `plugin.bin`。
|
||||
|
||||
> ⚠️ **v1.0.0 破坏性变更**:外部插件从 C ABI 动态库改为**子进程 + 共享内存**。
|
||||
>
|
||||
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
|
||||
> 会跳过并报可操作错误,不崩溃。
|
||||
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `hmapdev`(原 `plugindev`)重编。
|
||||
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
|
||||
> 它现在只用于区分 Lua 插件。
|
||||
> - 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
|
||||
> - Windows 从「只下发 3 个 stage 字段、无写回」升级到 16 字段全可见 + 写回,
|
||||
> 与 Unix 共用同一套 RPC 实现。
|
||||
|
||||
### 构建目标与多平台打包(bundle)
|
||||
|
||||
**`plugindev build` 默认就是 bundle 模式**(`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64,生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
|
||||
**`hmapdev build` 默认就是 bundle 模式**(`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64,生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
|
||||
|
||||
```bash
|
||||
plugindev build # 默认 bundle,输出 dist/myplugin_bundle.hmap
|
||||
plugindev build --bundle # 显式开启 bundle(同上)
|
||||
plugindev build --no-bundle # 关闭 bundle,按 plg.json 的 targets 逐平台构建
|
||||
hmapdev build # 默认 bundle,输出 dist/myplugin_bundle.hmap
|
||||
hmapdev build --bundle # 显式开启 bundle(同上)
|
||||
hmapdev build --no-bundle # 关闭 bundle,按 plg.json 的 targets 逐平台构建
|
||||
```
|
||||
|
||||
注意:
|
||||
@ -261,7 +278,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
|
||||
### 入口点
|
||||
|
||||
`plugindev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
|
||||
`hmapdev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
|
||||
|
||||
```go
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
@ -269,7 +286,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
}
|
||||
```
|
||||
|
||||
编译时 `plugindev build` 根据目标平台自动生成 C ABI bridge 代码(`z_bridge_gen.go` + `z_entry.c`),无需手动编写。Windows DLL 和 Linux/macOS .so 共享同一入口。
|
||||
编译时 `hmapdev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑,仅跨进程资源传递机制不同(Unix 继承 fd,Windows 命名内核对象)。
|
||||
|
||||
### PluginSDK 核心 API
|
||||
|
||||
@ -321,7 +338,7 @@ s.RegisterTool("weather_query", sdk.ToolDef{
|
||||
└── 否 → 正常记忆,无需额外处理
|
||||
```
|
||||
|
||||
> **注意**:`Cleaner` 是 Go `func` 类型(`json:"-"`),不能跨 C ABI 边界序列化,因此 C/C++/Rust 等远程插件无法使用。**Lua 插件不受此限**:def 表中直接传 Lua 函数即可(`cleaner = function(text) return text end`),Go 桥接层会在计算层调用时逐次回调 Lua。
|
||||
> **注意**:`Cleaner` 是 Go `func` 类型(`json:"-"`),不能跨进程序列化,因此 C/C++/Rust 等远程插件无法使用。**Lua 插件不受此限**:def 表中直接传 Lua 函数即可(`cleaner = function(text) return text end`),Go 桥接层会在计算层调用时逐次回调 Lua。
|
||||
|
||||
#### 阶段钩子 — 干预消息处理流
|
||||
|
||||
@ -526,7 +543,7 @@ Lua 插件运行在内核进程内的 gopher-lua 解释器中(单 Lua 状态 +
|
||||
|
||||
- **被动回调模型**:`main.lua` 仅在加载时执行一次,此后插件的工具、阶段钩子、输出/输入通道、注册 API 全部由内核事件驱动回调 Lua 函数;插件不能自己启动后台任务。
|
||||
- **无并发/无常驻服务能力**:Lua 侧没有 goroutine、协程调度、`os`/`io` 库和 socket 监听能力,唯一主动出站通道是 `sdk.http.get/post`(同步请求)。任何阻塞循环都会持锁卡死该插件的所有调用。
|
||||
- **常驻服务(如监听端口、后台轮询、定时任务)请使用 Go 插件**(工具链编译的 `.so`/`.dll`,可自行启动 goroutine,参见 webui/cli 插件)。Lua 插件的等价做法是事件驱动:注册工具/阶段钩子/通道由内核回调,或经 `sdk.http` 与外部进程交互。
|
||||
- **常驻服务(如监听端口、后台轮询、定时任务)请使用 Go 插件**(工具链编译的 `plugin.bin`,可自行启动 goroutine,参见 webui/cli 插件)。Lua 插件的等价做法是事件驱动:注册工具/阶段钩子/通道由内核回调,或经 `sdk.http` 与外部进程交互。
|
||||
|
||||
### 插件结构
|
||||
|
||||
@ -575,25 +592,28 @@ lua main.lua
|
||||
|
||||
### Lua SDK API
|
||||
|
||||
Lua 插件的 `sdk.*` API 与外部插件(C ABI / 工具链编译的 `.so`/`.dll`)能力完全对齐:注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力对齐至 **SDK 1.3.0**(需内核 **1.4.0+**,也在 `v1.3.11` 的 Lua 对齐补丁中回填):注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||
|
||||
> 历史提醒:1.1–1.3 的媒体/注入标志位/优先级能力曾长期只在 Go 侧,Lua 侧静默缺失。现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住「mock 承诺的每个函数都有运行时绑定」。
|
||||
|
||||
**注册类**
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `sdk.log(level, msg)` | 日志输出 |
|
||||
| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description`、`parameters`、`no_memory`、`cleaner` |
|
||||
| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description`、`parameters`、`no_memory`、`context_policy`(`"none"`/`"prune"`)、`cleaner` |
|
||||
| `sdk.register_stage(stage, handler, scope)` | 注册阶段钩子;`scope` 为 `nil`/`"global"`(默认)或 `"own_tools"`(仅 `before_toolcall`/`after_toolcall` 且工具属于本插件时触发) |
|
||||
| `sdk.register_api(name)` | 注册 API |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory`、`cleaner` |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory`、`context_policy`、`cleaner` |
|
||||
| `sdk.register_input_channel(name, def)` | 注册输入通道;`def` 同上 |
|
||||
| `sdk.unregister_output_channel(name)` | 注销输出通道(随资源生灭的动态通道,如远程设备);返回 `(nil, err)` |
|
||||
| `sdk.set_auto_restart(enabled)` | 崩溃时内核自动拉起插件 |
|
||||
|
||||
**阶段钩子上下文**
|
||||
|
||||
`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message`、`user_id`、`group_id`、`phase`、`llm_text`、`final_text`、`no_memory`、`response`(已响应时)、`tool_calls`、`tool_results`。
|
||||
`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`。
|
||||
|
||||
**Stage 写回(ABI v2)**:handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与 C ABI v2 外部插件能力对齐):
|
||||
**Stage 写回**:handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与子进程外部插件能力对齐):
|
||||
|
||||
```lua
|
||||
sdk.register_stage("on_input", function(ctx)
|
||||
@ -620,20 +640,37 @@ end)
|
||||
| `sdk.inject_text(source, channel, text)` | 投递文本消息 |
|
||||
| `sdk.inject_interrupt(source, channel, text)` | 中断投递 |
|
||||
| `sdk.inject_text_no_memory(source, channel, text)` | 免记忆投递 |
|
||||
| `sdk.inject_text_opts` / `sdk.inject_interrupt_opts(source, channel, text, opts)` | 带标志位投递;`opts = { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }` |
|
||||
| `sdk.inject_input_sync(source, channel, text)` | ⚠️ **Lua 中不可用**:恒返回 `(nil, err)`。它要等本轮回复而 Lua 回调持有插件锁,必然自锁。需要同步等待请用 Go 插件,或用下面的异步注入 |
|
||||
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | 同上(不可用) |
|
||||
| `sdk.inject_input_media(source, channel, text, blocks)` | 注入文本 + 多模态内容块 |
|
||||
| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
|
||||
| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | ⚠️ **Lua 中不可用**(同 `inject_input_sync`) |
|
||||
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | 带媒体的中断注入 |
|
||||
| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
|
||||
| `sdk.set_tool_blocks(blocks)` | 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频) |
|
||||
|
||||
**数据类(与 C ABI 对齐,均返回 `(result, err)`)**
|
||||
`blocks` 每项形如:`{ type="text", text="..." }`、`{ type="image_url", image_url={ url="...", detail="high" } }`、`{ type="audio_url", audio_url={ url="..." } }`。`opts` 缺省即零值(记入记忆 + 不裁剪),与三参数版本等价。
|
||||
|
||||
**数据类(与子进程外部插件对齐,均返回 `(result, err)`)**
|
||||
|
||||
| 子表 | 函数 |
|
||||
|------|------|
|
||||
| `sdk.memory.*` | `recall(query, depth)`、`commit({triples})`、`introspect()`、`merge(source, target)`、`purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`、`insert({id,title,content})`、`remove(id)`、`stats()` |
|
||||
| `sdk.memory.*` | `recall(query, depth)`、`commit({triples})`(triple 支持 `subject/relation/object/confidence/subject_type/object_type/sentence_text/media_digests`)、`introspect()`、`merge(source, target)`、`purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`、`insert({id,title,content})`、`insert_with_media(doc, attachments)`、`remove(id)`、`stats()` |
|
||||
| `sdk.knowledge.*` | `search(query, limit)`、`add(tag, content)`、`list()` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` |
|
||||
| `sdk.llm.*` | `list_sources()`、`set_source(name)`、`current_source()` |
|
||||
| `sdk.social.*`(只读) | `get_person(name)`、`get_network(name, depth)`、`get_trait(name, trait)`、`get_relations(name)`、`list_persons()` |
|
||||
| `sdk.events.*` | `subscribe(event_type, handler)` → 返回取消订阅函数;handler 收到 `{type,source,timestamp,payload}` |
|
||||
| `sdk.plugin_mgr.*` | `reload_one(name)`、`list_loaded()`、`is_disabled(name)` |
|
||||
| `sdk.json.*` | `encode(val)`、`decode(str)` |
|
||||
| `sdk.http.*` | `get(url)`、`post(url, body, content_type)` |
|
||||
|
||||
`attachments` 每项:`{ digest=, mime=, name=, data=<base64> }`;带 `data` 是新内容(落进内容寻址存储),只带 `digest` 是引用已有内容。
|
||||
|
||||
> `sdk.events.subscribe` 的回调在内核事件发布 goroutine 上执行,且 Lua 是单状态 + 互斥锁——**回调内只做轻量转发,不可阻塞**,否则会卡死本插件的全部调用。
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
126
cmd/gui/devicebridge_dll.js
Normal file
126
cmd/gui/devicebridge_dll.js
Normal file
@ -0,0 +1,126 @@
|
||||
// DeviceBridge DLL 桥接模块
|
||||
// 提供设备桥共享库的 Node.js 封装,GUI 通过 FFI 调用 Go 编译的 DLL。
|
||||
// 优先尝试加载 DLL,失败则回退到纯 JS 实现(保留兼容)。
|
||||
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
let koffi = null;
|
||||
let bridgeLib = null;
|
||||
let _handle = null;
|
||||
|
||||
// DLL 路径
|
||||
function dllPath() {
|
||||
const dir = __dirname;
|
||||
const plat = os.platform();
|
||||
if (plat === 'win32') {
|
||||
return path.join(dir, 'devicebridge.dll');
|
||||
}
|
||||
// Linux/Mac 使用 .so/.dylib
|
||||
const ext = plat === 'darwin' ? 'dylib' : 'so';
|
||||
return path.join(dir, `devicebridge.${ext}`);
|
||||
}
|
||||
|
||||
// 尝试加载 FFI 库
|
||||
async function loadFFI() {
|
||||
try {
|
||||
koffi = require('koffi');
|
||||
return true;
|
||||
} catch (e) {
|
||||
try {
|
||||
const ffi = require('ffi-napi');
|
||||
const ref = require('ref-napi');
|
||||
// 使用 ffi-napi 作为备选
|
||||
return true;
|
||||
} catch (e2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 DLL
|
||||
function loadDLL() {
|
||||
const dll = dllPath();
|
||||
try {
|
||||
if (koffi) {
|
||||
return koffi.load(dll);
|
||||
}
|
||||
const ffi = require('ffi-napi');
|
||||
const ref = require('ref-napi');
|
||||
return ffi.Library(dll, {
|
||||
'devicebridge_new': ['pointer', ['string', 'string', 'string', 'string', 'pointer', 'int']],
|
||||
'devicebridge_start': ['int', ['pointer']],
|
||||
'devicebridge_stop': ['void', ['pointer']],
|
||||
'devicebridge_free': ['void', ['pointer']],
|
||||
'devicebridge_connected': ['int', ['pointer']],
|
||||
'devicebridge_device_id': ['string', ['pointer']],
|
||||
'devicebridge_send_result': ['int', ['pointer', 'string', 'string', 'string', 'string']],
|
||||
'devicebridge_send_event': ['void', ['pointer', 'string', 'string']],
|
||||
'devicebridge_send_status': ['void', ['pointer', 'string']],
|
||||
'devicebridge_send_data_start': ['void', ['pointer', 'string', 'string', 'string', 'int']],
|
||||
'devicebridge_send_data_chunk': ['int', ['pointer', 'pointer', 'int']],
|
||||
'devicebridge_send_data_end': ['void', ['pointer', 'string', 'string', 'string']],
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[devicebridge-dll] load failed:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 设备桥封装
|
||||
class DeviceBridgeDLL {
|
||||
constructor() {
|
||||
this.connected = false;
|
||||
this.deviceId = '';
|
||||
this._onCmd = null;
|
||||
this._onData = null;
|
||||
}
|
||||
|
||||
// 初始化并连接
|
||||
async start(gateway, token, deviceId, deviceName, caps, info) {
|
||||
bridgeLib = loadDLL();
|
||||
if (!bridgeLib) {
|
||||
throw new Error('DLL not loaded');
|
||||
}
|
||||
|
||||
// 构建 caps 数组
|
||||
const capsArr = caps.map(c => Buffer.from(c + '\0'));
|
||||
const capsPtr = Buffer.alloc(8 * capsArr.length);
|
||||
// 简化:实际 FFI 调用需要更复杂的参数处理
|
||||
// 这里使用 koffi 方式
|
||||
|
||||
if (koffi) {
|
||||
// 使用 koffi 调用
|
||||
try {
|
||||
// TODO: 实现 koffi 调用
|
||||
throw new Error('koffi not fully implemented');
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('FFI library not available. Install koffi or ffi-napi');
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (bridgeLib && _handle) {
|
||||
try {
|
||||
bridgeLib.devicebridge_stop(_handle);
|
||||
bridgeLib.devicebridge_free(_handle);
|
||||
} catch (e) {}
|
||||
_handle = null;
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
sendResult(reqId, status, output, error) {
|
||||
if (!bridgeLib || !_handle) return;
|
||||
try {
|
||||
bridgeLib.devicebridge_send_result(_handle, reqId, status, output || '', error || '');
|
||||
} catch (e) {
|
||||
console.error('[devicebridge-dll] sendResult error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DeviceBridgeDLL, loadFFI };
|
||||
1476
cmd/gui/main.js
1476
cmd/gui/main.js
File diff suppressed because it is too large
Load Diff
270
cmd/gui/package-lock.json
generated
270
cmd/gui/package-lock.json
generated
@ -7,6 +7,9 @@
|
||||
"": {
|
||||
"name": "homeagent-gui",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"koffi": "^3.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"asar": "^3.2.0",
|
||||
"electron": "^33.0.0",
|
||||
@ -936,6 +939,246 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-darwin-arm64": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.6.tgz",
|
||||
"integrity": "sha512-8FHyXGCZN7/iQf4f7W5BRysmtdlAFvSx6FpmX4u6wmkZiX/2e9hIRdGLiZYlHGudlcA18UmXB/cMiyhJ7fJkzA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-darwin-x64": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.6.tgz",
|
||||
"integrity": "sha512-uzx/jqFQuSHqgg1zaRidTBTCfj8Y9M0SDTO8HeoI9s9fJhiJ1mbB9TTwJO5c2hiMnuWg2m1byczC8BaIH6cG/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-freebsd-arm64": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.6.tgz",
|
||||
"integrity": "sha512-PjpTVrsCK5YTtixOw7VsseYXJOyoY6k0qBt+bf0T9h3wyV06y73rALsorFDDEoYpLUBZO7R6EIMs6CpUrEkNTQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-freebsd-ia32": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.6.tgz",
|
||||
"integrity": "sha512-ETYwL820HtFwYoOVzgyvmFmzTHRo9DJtGYTxa5Nb7ajaa5ldCum0jbmUJ3PMECxFTLxD5Q6PYZ8Xbp101bGeSg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-freebsd-x64": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.6.tgz",
|
||||
"integrity": "sha512-BkqxkNXhAAT9toU2stvLwx1iKHPDx7h08NCICyBbjYEXkCAEr84igTkpE5V3XJ9xZZ2gKll7VvdhxorHtHUqZw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-linux-arm64": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.6.tgz",
|
||||
"integrity": "sha512-cM4XPm9ljbCrcPgXjzFYjDNxDUvvuR7TCYaEoo1AKjwZT/vmWhu2xN3pomfbsHh6aVn80SFA3enufQnaETW1rQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-linux-ia32": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.6.tgz",
|
||||
"integrity": "sha512-l1SVTpO10iaQt8slbowJpzK4fbwQZ7ufj9tmCyAcIwWUpyAbPS83mJMctU72If6N9/gCS2wuRqwnYB2uPLLhLg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-linux-loong64": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.6.tgz",
|
||||
"integrity": "sha512-KpTJpMSbIdCVFU26ynt0xy4x15h+y6AwPJxj2+iVxJhCzJf4oisCPc0YH2VnutuLV2nVzSrFm7sL/WSOqPgkXw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-linux-riscv64": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.6.tgz",
|
||||
"integrity": "sha512-YdFNpsywnXiYOYQlDAatf7TJLnspbGXdmfwIZhf82kbKSflYUsq4tI6NmoUOzM89oIWDGpYqd4Hz3xL7tsyXsw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-linux-x64": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.6.tgz",
|
||||
"integrity": "sha512-Xx5mpr9VcaMCXfvbqIiLIWIL9Iuu6F4r3iMXg7+zZCqYUFZPFwJgiDQBLxctHv2OYgIfAoaaHMW0GC1cJkHfbA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-openbsd-ia32": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.6.tgz",
|
||||
"integrity": "sha512-39Np4QTxhTlTT6RRveIeP+TnbzrwuDJ0UMyHxEZ+oGtzmb9GqWhl9T1oyehG6v/O+c4BffafG2NwLkCZ7tDKWw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-openbsd-x64": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.6.tgz",
|
||||
"integrity": "sha512-3EynGn3ycQRqaMWGmUJ0tdtuQdStByqSy/tJ0ZGKWizbMGdFAE73YpgLsyd8BDvwnKWytVG/OLNb5nDHpfd9Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-win32-arm64": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.6.tgz",
|
||||
"integrity": "sha512-27FdPPRtT4xbO9bsd2OZa95M5YQ7bcJ8QjCRO57UUMI21REfkDegjqKwqo/CFlugxXlJf5IYtG2rq4BEYIrvxg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-win32-ia32": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.6.tgz",
|
||||
"integrity": "sha512-5mVelLKVDup4eoxZOpCzCyMPxoctsg+Qe4J9O5BP4KbBEdqoOEqaNEBBRgNzXcdr2g+GGfmIUo+oVR1NvIdiJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-win32-x64": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.6.tgz",
|
||||
"integrity": "sha512-lPKjAaHz0aoiZXT/wDVqH+joR5y3lCZj1s9Bk5qx/DGRq+0MK8Ib8VoqiBOrJ69NG5AsJSvn0tQDcSqfRgLmBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@malept/cross-spawn-promise": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz",
|
||||
@ -3764,6 +4007,33 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/koffi": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/koffi/-/koffi-3.1.6.tgz",
|
||||
"integrity": "sha512-ln60chEb3o7Du1ayjwl6BFiNN1wZK+3cTM2wWGiHLEzCY/FdTIN1ER5VWDwHq7J/j4tSnnrHaH5ABS1EO6+6ag==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@koromix/koffi-darwin-arm64": "3.1.6",
|
||||
"@koromix/koffi-darwin-x64": "3.1.6",
|
||||
"@koromix/koffi-freebsd-arm64": "3.1.6",
|
||||
"@koromix/koffi-freebsd-ia32": "3.1.6",
|
||||
"@koromix/koffi-freebsd-x64": "3.1.6",
|
||||
"@koromix/koffi-linux-arm64": "3.1.6",
|
||||
"@koromix/koffi-linux-ia32": "3.1.6",
|
||||
"@koromix/koffi-linux-loong64": "3.1.6",
|
||||
"@koromix/koffi-linux-riscv64": "3.1.6",
|
||||
"@koromix/koffi-linux-x64": "3.1.6",
|
||||
"@koromix/koffi-openbsd-ia32": "3.1.6",
|
||||
"@koromix/koffi-openbsd-x64": "3.1.6",
|
||||
"@koromix/koffi-win32-arm64": "3.1.6",
|
||||
"@koromix/koffi-win32-ia32": "3.1.6",
|
||||
"@koromix/koffi-win32-x64": "3.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/lazy-val": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"dev": "electron . --no-sandbox --dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"koffi": "^3.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"asar": "^3.2.0",
|
||||
|
||||
@ -44,5 +44,13 @@ contextBridge.exposeInMainWorld("homeagent", {
|
||||
deviceBridge: {
|
||||
get: () => ipcRenderer.invoke("device-bridge:get"),
|
||||
set: (cfg) => ipcRenderer.invoke("device-bridge:set", cfg),
|
||||
setAuthorized: (auth) =>
|
||||
ipcRenderer.invoke("device-bridge:setAuthorized", auth),
|
||||
},
|
||||
displays: {
|
||||
list: () => ipcRenderer.invoke("displays:list"),
|
||||
},
|
||||
audio: {
|
||||
list: () => ipcRenderer.invoke("audio:list"),
|
||||
},
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -18,6 +18,10 @@
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"
|
||||
onerror="console.warn('marked CDN failed')"
|
||||
></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"
|
||||
onerror="console.warn('DOMPurify CDN failed')"
|
||||
></script>
|
||||
<script>
|
||||
setTimeout(function () {
|
||||
if (!window.THREE) window._THREE_FAILED = true;
|
||||
|
||||
@ -45,8 +45,8 @@
|
||||
--bg-input: rgba(13, 18, 34, 0.75);
|
||||
--bg-hover: rgba(255, 255, 255, 0.06);
|
||||
--text-primary: #eef1f8;
|
||||
--text-secondary: #a7b0c4;
|
||||
--text-muted: #77809a;
|
||||
--text-secondary: #b8c1d6;
|
||||
--text-muted: #93a0b8;
|
||||
--border-color: rgba(255, 255, 255, 0.09);
|
||||
--accent: #ff7fac;
|
||||
--accent-bg: rgba(255, 127, 172, 0.14);
|
||||
@ -98,8 +98,8 @@
|
||||
--bg-input: rgba(255, 224, 233, 0.55);
|
||||
--bg-hover: rgba(255, 127, 172, 0.08);
|
||||
--text-primary: #3b2030;
|
||||
--text-secondary: #7a5c6b;
|
||||
--text-muted: #a48a96;
|
||||
--text-secondary: #6b4b5c;
|
||||
--text-muted: #8f6f7d;
|
||||
--border-color: rgba(201, 36, 98, 0.14);
|
||||
--accent: #c92462;
|
||||
--accent-bg: #ffe4e9;
|
||||
@ -290,7 +290,7 @@ body {
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
user-select: none;
|
||||
@ -707,13 +707,13 @@ body.maximized .tb-max svg {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin: 16px 0 8px;
|
||||
@ -901,7 +901,7 @@ select {
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
outline: none;
|
||||
@ -923,9 +923,9 @@ textarea {
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 3px;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
pre {
|
||||
@ -1597,10 +1597,10 @@ code {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.settings-tabs span {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
padding: 7px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
@ -1619,18 +1619,18 @@ code {
|
||||
}
|
||||
.settings-key {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.kv-row {
|
||||
display: flex;
|
||||
padding: 6px 0;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--kv-border);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.kv-row .key {
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@ -1716,8 +1716,8 @@ code {
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.conn-item .conn-url {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.conn-item .conn-actions {
|
||||
@ -2191,3 +2191,284 @@ td .switch {
|
||||
.kv-row .val .switch {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* ===== 运行态面板:阶段管道 + 中断队列(与 WebUI 总览同一套设计语言)=====
|
||||
两块都统一成**等大表框**,与 KPI 卡同一种骨架。
|
||||
此前桌面版总览只有四个数字卡:既看不到「这一轮走到哪一步」,
|
||||
也看不到四级中断队列的积压。 */
|
||||
.stat-card .stat-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.3px;
|
||||
margin: 14px 0 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.rt-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 0;
|
||||
}
|
||||
/* 行内小图标:一律 SVG,不使用 emoji/符号字符。 */
|
||||
.rt-ico {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 auto;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vertical-align: -1.5px;
|
||||
}
|
||||
.rt-pipe-row {
|
||||
display: grid;
|
||||
/* 100px 下限:容器再窄也要保证 5 个框一行,不留「4 个 + 1 个」的孤行 */
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 2px 0 8px;
|
||||
transition: opacity 0.3s var(--ease-out);
|
||||
}
|
||||
.rt-pipe-idle {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.rt-pipe-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 11px 12px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
min-width: 0;
|
||||
transition:
|
||||
border-color 0.25s var(--ease-out),
|
||||
box-shadow 0.25s var(--ease-out),
|
||||
background 0.25s var(--ease-out);
|
||||
}
|
||||
.rt-pipe-cell.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.rt-pipe-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rt-pipe-head > b {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rt-pipe-head .rt-ico {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.rt-pipe-cell.active .rt-pipe-head > b,
|
||||
.rt-pipe-cell.active .rt-pipe-head .rt-ico {
|
||||
color: var(--accent);
|
||||
}
|
||||
.rt-pipe-events {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-height: 23px;
|
||||
}
|
||||
/* 「工具」格的滚动视口:固定一行高,只露最新一条(与 WebUI 同一口径)。 */
|
||||
.rt-pipe-scroll {
|
||||
flex-wrap: nowrap;
|
||||
height: 23px;
|
||||
min-height: 23px;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
}
|
||||
.rt-pipe-scroll .rt-chip {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
.rt-chip-enter {
|
||||
animation: rt-chip-scroll-in 0.3s var(--ease-out);
|
||||
}
|
||||
@keyframes rt-chip-scroll-in {
|
||||
from {
|
||||
transform: translateY(115%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.rt-scroll-count {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
font-style: normal;
|
||||
font-size: 10.5px;
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-loop {
|
||||
display: inline-flex;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.rt-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
max-width: 170px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.rt-chip-t {
|
||||
font-weight: inherit;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rt-chip-tool {
|
||||
border-color: rgba(136, 192, 208, 0.42);
|
||||
color: var(--frost-300);
|
||||
}
|
||||
.rt-chip-output {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.rt-chip-stage {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.rt-chip-none {
|
||||
opacity: 0.35;
|
||||
border-style: dashed;
|
||||
}
|
||||
.rt-chip-n {
|
||||
font-style: normal;
|
||||
opacity: 0.75;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* 队列:5 个等大框(L4/L3/L2/L1 + 排队),一行排开 */
|
||||
.rt-queues {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.rt-qcell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 11px 12px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
min-width: 0;
|
||||
transition:
|
||||
border-color 0.25s var(--ease-out),
|
||||
box-shadow 0.25s var(--ease-out);
|
||||
}
|
||||
.rt-qhead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rt-qhead > b {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.rt-qhead > span {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rt-qnum {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-qmeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-qcell.rt-lv-4 .rt-qhead > b { color: #ff5c7a; }
|
||||
.rt-qcell.rt-lv-3 .rt-qhead > b { color: #ffa657; }
|
||||
.rt-qcell.rt-lv-2 .rt-qhead > b { color: var(--sakura-400); }
|
||||
.rt-qcell.rt-lv-1 .rt-qhead > b { color: var(--frost-300); }
|
||||
.rt-qcell.rt-lv-q .rt-qhead > b { color: #a3be8c; }
|
||||
/* 有积压时整个框描边点亮:一眼看出哪条队列在堵 */
|
||||
.rt-qcell.rt-lv-4.rt-active { border-color: rgba(255, 92, 122, 0.55); }
|
||||
.rt-qcell.rt-lv-3.rt-active { border-color: rgba(255, 166, 87, 0.55); }
|
||||
.rt-qcell.rt-lv-2.rt-active { border-color: var(--sakura-400); }
|
||||
.rt-qcell.rt-lv-1.rt-active { border-color: var(--frost-300); }
|
||||
.rt-qcell.rt-lv-q.rt-active { border-color: #a3be8c; }
|
||||
/* 排队队列无级别:虚线框与四级中断区分(另一**类别**,不是另一优先级) */
|
||||
.rt-qcell.rt-qcell-queued {
|
||||
border-style: dashed;
|
||||
}
|
||||
/* 队列格槽:固定可见的「车位」,占用多少一眼可数。
|
||||
用进度条时队列为 0 宽度就是 0,整行只剩文字,看上去就是「这块空着」。 */
|
||||
.rt-slots {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 3px;
|
||||
height: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rt-slots > i {
|
||||
flex: 1 1 0;
|
||||
min-width: 3px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition:
|
||||
background 0.25s var(--ease-out),
|
||||
box-shadow 0.25s var(--ease-out);
|
||||
}
|
||||
.rt-slots.rt-lv-4 > i.on { background: #ff5c7a; box-shadow: 0 0 6px rgba(255, 92, 122, 0.45); }
|
||||
.rt-slots.rt-lv-3 > i.on { background: #ffa657; box-shadow: 0 0 6px rgba(255, 166, 87, 0.4); }
|
||||
.rt-slots.rt-lv-2 > i.on { background: var(--sakura-400); box-shadow: 0 0 6px rgba(255, 127, 172, 0.4); }
|
||||
.rt-slots.rt-lv-1 > i.on { background: var(--frost-300); box-shadow: 0 0 6px rgba(136, 192, 208, 0.4); }
|
||||
.rt-slots.rt-lv-q > i.on { background: #a3be8c; box-shadow: 0 0 6px rgba(163, 190, 140, 0.4); }
|
||||
.rt-slots-more {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
margin-left: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
869
cmd/homed/bootstrap.go
Normal file
869
cmd/homed/bootstrap.go
Normal file
@ -0,0 +1,869 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/ipc"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log"
|
||||
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/recovery"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
// defaultSystemPrompt 是内置默认人格模板:不含版本号字面量,
|
||||
// 被问版本时以运行时快照为准(历史上写死版本号导致实例自称旧版本)。
|
||||
const defaultSystemPrompt = `你是 HomeAgent,一个持续运行的个人管家。
|
||||
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。
|
||||
如需切换回复通道,使用 output_set_channel。
|
||||
如需异步发送消息或通知,使用 output_send 指定通道和内容。
|
||||
使用 output_list_channels 查看可用通道及其能力。
|
||||
|
||||
可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具:
|
||||
- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实)
|
||||
- knowledge_* — 知识库(查阅预设知识文档)
|
||||
- doc_* — 文档记忆(近期对话的存档,查询后自动清除)
|
||||
- person_* — 人物特质与社交关系网
|
||||
- llm_* — LLM 源管理(列出/切换模型提供商)
|
||||
- output_* — 输出通道管理(切换/发送消息)
|
||||
- timer_set — 设置定时提醒
|
||||
- plgreload — 热重载插件
|
||||
- spawn_child — 生成子 Agent 异步执行独立任务(可传 max_turns 控制工具轮数,默认 5)
|
||||
|
||||
并行策略:遇到多个互不依赖的子任务时,优先并行 spawn 多个子 Agent 而非自己串行逐个执行;
|
||||
长耗时任务(批量处理、多轮搜索汇总)也应交给子 Agent,避免阻塞当前对话。
|
||||
- describe_image — 描述用户上传的图片
|
||||
- transcribe_audio — 转写用户上传的音频
|
||||
- ocr_image — 识别图片中的文字
|
||||
|
||||
命令与文件操作策略:
|
||||
- cmd_run 经完整 shell(bash)执行,支持管道、分号、&&、命令替换、heredoc、重定向。
|
||||
- 多步交互式程序(vim/top/ssh 会话、需要持续输入的进程)用 terminal_create 创建终端,
|
||||
terminal_write 发送输入、terminal_read 读输出——不要用 cmd_run 硬等交互程序退出。
|
||||
- 写文件优先 files_write(原子+留档),生成多行内容时可用 heredoc 或 files_write,
|
||||
不要用 echo 拼接长文本。
|
||||
- 读用户发来的文件用 files_read;向 webui 回传图片/文件用 output_send__webui(type=image/file)。
|
||||
|
||||
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。
|
||||
|
||||
回复你的真实想法,用自然语言与用户交流。不要在回复中使用 emoji 表情。`
|
||||
|
||||
// setupLogging 初始化日志:行号前缀 + 同时输出到控制台与 <data>/log/ 下的本次启动文件。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func setupLogging(dataDir string) string {
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
|
||||
// 文件日志:同时输出到控制台和 data/log/ 目录
|
||||
logDir := filepath.Join(dataDir, "log")
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
log.Printf("[homed] warning: cannot create log dir: %v", err)
|
||||
} else {
|
||||
logPath := filepath.Join(logDir, fmt.Sprintf("homed_%s.log", time.Now().Format("2006-01-02_15-04-05")))
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: cannot open log file: %v", err)
|
||||
} else {
|
||||
log.SetOutput(io.MultiWriter(os.Stderr, logFile))
|
||||
log.Printf("[homed] logging to %s", logPath)
|
||||
}
|
||||
}
|
||||
|
||||
return logDir
|
||||
}
|
||||
|
||||
// ensureDataDirs 建好启动期需要的全部目录,返回 agent 的 overlayfs 工作目录。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func ensureDataDirs(dataDir string) string {
|
||||
agentWorkDir := filepath.Join(dataDir, "agentfs")
|
||||
dirs := []string{
|
||||
dataDir,
|
||||
filepath.Join(dataDir, "snapshots"),
|
||||
filepath.Join(dataDir, "plugins"),
|
||||
filepath.Join(dataDir, "changesets"),
|
||||
filepath.Join(dataDir, "memory"),
|
||||
filepath.Join(dataDir, "memory", "raw"),
|
||||
filepath.Join(dataDir, "adapters"),
|
||||
agentWorkDir,
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
log.Fatalf("create dir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
return agentWorkDir
|
||||
}
|
||||
|
||||
// memoryStack 聚合记忆侧组件:图库、索引器、社交图、蒸馏器。
|
||||
type memoryStack struct {
|
||||
db *memory.GraphDB
|
||||
indexer *memory.Indexer
|
||||
social *social.SocialStore
|
||||
distiller *pipeline.Distiller
|
||||
}
|
||||
|
||||
// initMemoryStack 初始化图记忆 / 索引 / 社交图 / 蒸馏管线。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initMemoryStack(dataDir string) (*memoryStack, func()) {
|
||||
memDB, err := memory.NewGraphDB(filepath.Join(dataDir, "memory", "graph.db"))
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: memory init failed: %v", err)
|
||||
memDB = nil
|
||||
} else {
|
||||
log.Printf("[homed] graph memory initialized")
|
||||
}
|
||||
if memDB != nil {
|
||||
}
|
||||
|
||||
memIdx := memory.NewIndexer(memDB)
|
||||
memIdx.Sync() // 启动时立即同步,避免前30分钟空窗
|
||||
socialStore := social.New(memDB)
|
||||
|
||||
distiller := pipeline.NewDistiller(memDB, dataDir, pipeline.DistillerConfig{
|
||||
Interval: 10 * time.Minute,
|
||||
RetentionDays: 7,
|
||||
BatchSize: 50,
|
||||
})
|
||||
if memDB != nil {
|
||||
distiller.Start()
|
||||
defer distiller.Stop()
|
||||
}
|
||||
|
||||
return &memoryStack{db: memDB, indexer: memIdx, social: socialStore, distiller: distiller},
|
||||
func() {
|
||||
// 与原 main 的两个 defer 同序(LIFO):先停蒸馏器,再关图库。
|
||||
if memDB != nil {
|
||||
distiller.Stop()
|
||||
}
|
||||
if memDB != nil {
|
||||
memDB.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startMemoryCandidateConsumer 起一个常驻 goroutine:把 eventbus 上的 memory_candidate 事件写进文本记忆并喂给蒸馏管线。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startMemoryCandidateConsumer(ctx context.Context, iom *agentIO.IOManager, textMem *text.Memory, memDB *memory.GraphDB, distiller *pipeline.Distiller) {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case evt, ok := <-iom.OutputChan():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if evt.Target == "memory" && evt.Type == "memory_candidate" {
|
||||
source, _ := evt.Payload["source"].(string)
|
||||
input, _ := evt.Payload["input"].(string)
|
||||
response, _ := evt.Payload["response"].(string)
|
||||
toolsUsed, _ := evt.Payload["tools_used"].([]string)
|
||||
toolResults, _ := evt.Payload["tool_results"].([]interface{})
|
||||
agentID, _ := evt.Payload["agent_id"].(string)
|
||||
|
||||
if input != "" && textMem != nil {
|
||||
te := text.Event{
|
||||
Timestamp: time.Now().Unix(),
|
||||
Source: source,
|
||||
Input: input,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
AgentID: agentID,
|
||||
}
|
||||
if err := textMem.Append(te); err != nil {
|
||||
log.Printf("[homed] text memory append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if input != "" && memDB != nil {
|
||||
distiller.Append("agent", "user", input)
|
||||
}
|
||||
if response != "" && memDB != nil {
|
||||
distiller.Append("agent", "assistant", response)
|
||||
}
|
||||
|
||||
// 工具输出接入蒸馏管线
|
||||
for _, tr := range toolResults {
|
||||
if trMap, ok := tr.(map[string]interface{}); ok {
|
||||
if text, ok := trMap["output"].(string); ok && text != "" && memDB != nil {
|
||||
distiller.Append("agent", "tool", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// initLLMProviders 按配置注册全部 LLM 源(每个源经 Lua 适配器协议转换),并把各适配器的并发额度汇总回 Lua VM。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initLLMProviders(cfg *types.Config, luaVM *luapkg.VM, baseAPIKey string) *agentAPI.ProviderManager {
|
||||
providerMgr := agentAPI.NewProviderManager()
|
||||
adapterConcurrency := map[string]int{}
|
||||
for _, src := range cfg.LLM.Sources {
|
||||
if !agentAPI.IsValidSourceConfig(src.Name, src.BaseURL, src.Model, src.Adapter) {
|
||||
log.Printf("[homed] skip invalid llm source %q (base_url=%q model=%q adapter=%q)", src.Name, src.BaseURL, src.Model, src.Adapter)
|
||||
continue
|
||||
}
|
||||
key := src.APIKey
|
||||
if key == "" {
|
||||
key = baseAPIKey
|
||||
}
|
||||
luaProvider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
|
||||
Model: src.Model,
|
||||
BaseURL: src.BaseURL,
|
||||
APIKey: key,
|
||||
Temperature: cfg.LLM.Temperature,
|
||||
MaxTokens: cfg.LLM.MaxTokens,
|
||||
ContextWindow: src.ContextWindow,
|
||||
MaxConcurrent: src.MaxConcurrent,
|
||||
Priority: src.Priority,
|
||||
Vision: src.Vision,
|
||||
Audio: src.Audio,
|
||||
}, luaVM, src.Name, src.Adapter)
|
||||
providerMgr.Register(src.Name, luaProvider)
|
||||
if src.Adapter != "" {
|
||||
adapterConcurrency[src.Adapter] += src.MaxConcurrent
|
||||
}
|
||||
}
|
||||
luaVM.ConfigureConcurrency(adapterConcurrency)
|
||||
if cfg.LLM.Provider != "" {
|
||||
providerMgr.SetDefault(cfg.LLM.Provider)
|
||||
}
|
||||
|
||||
return providerMgr
|
||||
}
|
||||
|
||||
// initDocStore 启动文档记忆。flush 的唯一入口是 Stop(),所以关停时必须调用它。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initDocStore(cfg *types.Config) (*document.Store, func()) {
|
||||
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
// 关停时落盘。文档记忆的内存态变更(迁移结果、访问计数等)只在 flush
|
||||
// 里写盘,而 flush 的唯一入口是 Stop()——此前全仓无人调用它,
|
||||
// 于是迁移结果永不落盘、每次启动白算一遍。
|
||||
|
||||
return docStore, func() { docStore.Stop() }
|
||||
}
|
||||
|
||||
// initMediaStore 按开关启动内容寻址的媒体存储;开不起来只告警(媒体记忆非对话必需品)。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initMediaStore(cfgReg *internalConfig.ConfigRegistry, cfg *types.Config) (*media.Store, func()) {
|
||||
var mediaStore *media.Store
|
||||
if cfgReg.GetBool("core.memory.media.enabled", true) {
|
||||
mediaDir := cfgReg.GetString("core.memory.media.dir",
|
||||
filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
|
||||
ms, err := media.New(mediaDir)
|
||||
if err != nil {
|
||||
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
|
||||
log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err)
|
||||
} else {
|
||||
mediaStore = ms
|
||||
st := mediaStore.Stats()
|
||||
log.Printf("[homed] media store active: %v 条 / %v 字节",
|
||||
st["count"], st["total_bytes"])
|
||||
}
|
||||
}
|
||||
|
||||
return mediaStore, func() {
|
||||
if mediaStore != nil {
|
||||
mediaStore.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initMultimodalSpace 从公共注册表打开多模态向量 provider。返回 (空间, provider 名, 失败原因, cleanup):后两个值只用于状态报告。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initMultimodalSpace(cfgReg *internalConfig.ConfigRegistry) (vector.MultimodalEmbedder, string, string, func()) {
|
||||
var multimodalSpace vector.MultimodalEmbedder
|
||||
// 这两个值只用于状态报告(healthcheck_kernel 的 onnx 段):
|
||||
// 「配了哪个 provider」与「为什么没启用」,避免只能看到 false 却不知原因。
|
||||
var mmProviderName, mmErr string
|
||||
var closeAdapted func()
|
||||
if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" {
|
||||
mmProviderName = mmProvider
|
||||
opts := map[string]string{}
|
||||
const optPrefix = "core.memory.multimodal_space.options."
|
||||
for _, key := range cfgReg.List("core.memory.multimodal_space.options.") {
|
||||
opts[strings.TrimPrefix(key, optPrefix)] = cfgReg.GetString(key, "")
|
||||
}
|
||||
provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts})
|
||||
if err != nil {
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v(多模态向量检索已禁用;已注册: %s)",
|
||||
mmProvider, err, strings.Join(embedding.Names(), ", "))
|
||||
} else if adapted, err := vector.AdaptProvider(provider); err != nil {
|
||||
provider.Close()
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v(多模态向量检索已禁用)", mmProvider, err)
|
||||
} else {
|
||||
multimodalSpace = adapted
|
||||
info := provider.Info()
|
||||
// 指纹可能很长(模型文件哈希),日志里只取前 12 个字符便于对照。
|
||||
shortFP := info.Fingerprint
|
||||
if len(shortFP) > 12 {
|
||||
shortFP = shortFP[:12]
|
||||
}
|
||||
log.Printf("[homed] multimodal space active: provider=%s dim=%d fp=%s modalities=%v",
|
||||
mmProvider, info.Dimension, shortFP, info.Modalities)
|
||||
}
|
||||
}
|
||||
|
||||
if closeAdapted == nil {
|
||||
closeAdapted = func() {}
|
||||
}
|
||||
return multimodalSpace, mmProviderName, mmErr, closeAdapted
|
||||
}
|
||||
|
||||
// initKnowledgeStore 启动知识库。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initKnowledgeStore(cfg *types.Config) *knowledge.Store {
|
||||
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
|
||||
if err := ks.Start(); err != nil {
|
||||
log.Printf("[homed] warning: knowledge store: %v", err)
|
||||
} else {
|
||||
log.Printf("[homed] knowledge store active with %d items", len(ks.List()))
|
||||
}
|
||||
|
||||
return ks
|
||||
}
|
||||
|
||||
// loadPersonality 按「个人文件 > 配置项」的优先级解析人格内容,并对腐坏内容告警。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func loadPersonality(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry) *agentPkg.Personality {
|
||||
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||
personality, err := agentPkg.LoadPersonality(personalPath)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: load personality: %v", err)
|
||||
}
|
||||
if personality != nil && personality.Content != "" {
|
||||
log.Printf("[homed] 人格来源=文件 %s(优先于配置项),%d 字节", personalPath, len(personality.Content))
|
||||
if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 {
|
||||
log.Printf("[homed] warning: 人格文件含会腐坏的内容 %v — 建议迁到配置项 core.agent.personal_prompt"+
|
||||
"(默认模板不含版本号,被问版本时以运行时快照为准)", hints)
|
||||
}
|
||||
} else if pv := cfgReg.GetString("core.agent.personal_prompt", internalConfig.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" {
|
||||
personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"}
|
||||
log.Printf("[homed] 人格来源=配置项 core.agent.personal_prompt,%d 字节", len(pv))
|
||||
} else {
|
||||
log.Printf("[homed] 人格来源=无(配置项为空且无人格文件)")
|
||||
}
|
||||
|
||||
return personality
|
||||
}
|
||||
|
||||
// newStageAndRegistry 建阶段管道与插件注册表,把内核依赖接到注册表上。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func newStageAndRegistry(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, iom *agentIO.IOManager,
|
||||
evBus *events.Bus, memDB *memory.GraphDB, textMem *text.Memory, docStore *document.Store,
|
||||
mediaStore *media.Store, ks *knowledge.Store, providerMgr *agentAPI.ProviderManager,
|
||||
dataDir string) (*agentCore.StageHost, *plugin.Registry) {
|
||||
stageHost := agentCore.NewStageHost()
|
||||
|
||||
pluginReg := plugin.NewRegistry()
|
||||
pluginReg.SetIOManager(iom)
|
||||
pluginReg.SetEventBus(evBus)
|
||||
pluginReg.SetMemory(memDB)
|
||||
pluginReg.SetTextMemory(textMem)
|
||||
pluginReg.SetDocStore(docStore)
|
||||
pluginReg.SetMediaStore(mediaStore) // 插件写入的记忆也走媒体链路;nil 时静默降级
|
||||
pluginReg.SetKnowledge(ks)
|
||||
pluginReg.SetProviderManager(providerMgr)
|
||||
pluginReg.SetConfigRegistry(cfgReg)
|
||||
pluginReg.SetPluginDir(cfg.Plugin.Dir)
|
||||
pluginReg.SetDataDir(dataDir) // 插件 SettingsAPI.DataDir() 的数据根目录
|
||||
|
||||
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost
|
||||
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
log.Printf("[homed] SetToolRegistrar registering tool: %s (plugin=%s)", name, def.Plugin)
|
||||
return stageHost.RegisterTool(name, def, handler)
|
||||
})
|
||||
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||
stageHost.RegisterStage(stage, handler)
|
||||
})
|
||||
pluginReg.SetAPIRegistrar(func(name string) error {
|
||||
return nil
|
||||
})
|
||||
pluginReg.SetToolCleaner(stageHost)
|
||||
|
||||
return stageHost, pluginReg
|
||||
}
|
||||
|
||||
// newMainAgent 组装主 Agent:把内核各面(IO/记忆/文档/知识/媒体/社交/文本/插件/状态)接进 AgentConfig。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, provider agentAPI.Provider,
|
||||
providerMgr *agentAPI.ProviderManager, iom *agentIO.IOManager, memDB *memory.GraphDB,
|
||||
memIdx *memory.Indexer, trk *tracker.Tracker, docStore *document.Store, ks *knowledge.Store,
|
||||
socialStore *social.SocialStore, textMem *text.Memory, mediaStore *media.Store,
|
||||
personality *agentPkg.Personality, pluginReg *plugin.Registry, embedder *memory.StaticEmbedder,
|
||||
multimodalSpace vector.MultimodalEmbedder, mmProviderName, mmErr string,
|
||||
stageHost *agentCore.StageHost, evBus *events.Bus) *agentCore.Agent {
|
||||
sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultSystemPrompt)
|
||||
if sysPrompt == "" {
|
||||
sysPrompt = defaultSystemPrompt
|
||||
}
|
||||
|
||||
agent := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "main",
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
Personality: personality,
|
||||
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
||||
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
|
||||
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
// DataDir:驻留子的 temp 图库锚点(<data>/residents/<id>/graph.db)。
|
||||
// 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。
|
||||
DataDir: cfg.Daemon.DataDir,
|
||||
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
||||
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
|
||||
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
|
||||
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
|
||||
MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10),
|
||||
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
||||
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
||||
Embedder: embedder,
|
||||
MultimodalSpace: multimodalSpace,
|
||||
EmbeddingProvider: mmProviderName,
|
||||
EmbeddingError: mmErr,
|
||||
StageHost: stageHost,
|
||||
EventBus: evBus,
|
||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
||||
InputProcessing: cfg.InputProcessing,
|
||||
})
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
// initONNXParser 初始化依存句法分析器(内嵌 ONNX 模型,失败则退回规则引擎)。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initONNXParser(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry) {
|
||||
modelPath := cfgReg.GetString("core.agent.onnx_model_path", "")
|
||||
onnxParser, err := nlp.NewONNXParser(nlp.ONNXConfig{
|
||||
ModelPath: modelPath,
|
||||
DataDir: filepath.Join(cfg.Daemon.DataDir, "nlp"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[homed] warn: ONNX parser init: %v, using fallback", err)
|
||||
} else {
|
||||
nlp.SetDefaultParser(onnxParser)
|
||||
log.Printf("[homed] dep parser initialized (model: %s)", modelPath)
|
||||
}
|
||||
}
|
||||
|
||||
// loadPlugins 建插件目录、按启动模式决定 allowlist,然后加载全部插件。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func loadPlugins(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
|
||||
stageHost *agentCore.StageHost, bootMode, dataDir string) {
|
||||
// Auto-create plugins directory (without hardcoding plugin names)
|
||||
os.MkdirAll(cfg.Plugin.Dir, 0755)
|
||||
|
||||
// failback 受限启动:仅装载 failback 插件集(webfetch/files/cmd 为内核内置,
|
||||
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论)
|
||||
if bootMode == "failback" {
|
||||
list := cfgReg.GetString("core.agent.failback_plugins", "webui,pluginmgr,recoverydiag")
|
||||
// 优先使用 guard.yaml 经过 recovery 任务下发的插件集(guard 是 failback 权威)
|
||||
if task, terr := recovery.LoadTask(recovery.TaskPath(dataDir)); terr == nil && len(task.Plugins()) > 0 {
|
||||
list = strings.Join(task.Plugins(), ",")
|
||||
}
|
||||
var names []string
|
||||
for _, s := range strings.Split(list, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
pluginReg.SetLoadAllowlist(names)
|
||||
log.Printf("[homed] failback boot: plugin allowlist = %v", names)
|
||||
}
|
||||
|
||||
// Load all plugins — each scans its own dir and is loaded via factory or .so
|
||||
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil {
|
||||
log.Printf("[homed] warning: load plugins: %v", err)
|
||||
}
|
||||
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
||||
}
|
||||
|
||||
// startAgentRuntime 接线技能索引、起日志管理、启动 agent,返回逆序关停的 cleanup。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startAgentRuntime(cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
|
||||
agent *agentCore.Agent, logDir string, ctx context.Context) func() {
|
||||
// 技能索引接线:skillmgr 插件实现 SkillIndexProvider 时注入 agent(方案B prompt 注入)
|
||||
if sp := pluginReg.Get("skillmgr"); sp != nil {
|
||||
if prov, ok := sp.(agentCore.SkillIndexProvider); ok {
|
||||
agent.SetSkillIndexProvider(prov)
|
||||
log.Printf("[homed] skill index wired from skillmgr plugin")
|
||||
}
|
||||
}
|
||||
|
||||
// 日志管理:层级压缩 + 保留策略
|
||||
logManager := logpkg.NewManager(logDir, cfgReg)
|
||||
go logManager.Start(ctx)
|
||||
|
||||
agent.Start()
|
||||
|
||||
return func() {
|
||||
// 与原 main 的两个 defer 同序(LIFO):先停 agent,再停日志管理。
|
||||
agent.Stop()
|
||||
logManager.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// startIPCServer 起 PING/ACK 心跳服务(含 kernel 状态快照),返回仅在启动成功后生效的 cleanup。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startIPCServer(dataDir, bootMode string, agent *agentCore.Agent) (*ipc.Server, func()) {
|
||||
started := false
|
||||
ipcServer := ipc.NewServer(dataDir, func() *ipc.Status {
|
||||
st := agent.GetKernelStatus()
|
||||
llmOK := st != nil && st.LLM.Available
|
||||
tools := 0
|
||||
if st != nil {
|
||||
tools = len(st.Tools)
|
||||
}
|
||||
uptime := int64(0)
|
||||
if st != nil {
|
||||
if d, err := time.ParseDuration(st.Uptime); err == nil {
|
||||
uptime = int64(d.Seconds())
|
||||
}
|
||||
}
|
||||
return &ipc.Status{
|
||||
PID: os.Getpid(),
|
||||
Boot: bootMode,
|
||||
UptimeSec: uptime,
|
||||
LLMOK: &llmOK,
|
||||
Tools: tools,
|
||||
LastDiag: lastDiagSummary(dataDir),
|
||||
}
|
||||
})
|
||||
if err := ipcServer.Start(); err != nil {
|
||||
log.Printf("[homed] warning: ipc heartbeat server: %v", err)
|
||||
} else {
|
||||
}
|
||||
|
||||
return ipcServer, func() {
|
||||
if started {
|
||||
ipcServer.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startSupervisorRuntime 把真实存活源与重启通道接到 supervisor 上,返回重启请求通道。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startSupervisorRuntime(sup *supervisor.Daemon, trk *tracker.Tracker, agent *agentCore.Agent) chan struct{} {
|
||||
sup.SetTracker(trk)
|
||||
sup.RegisterAgent("main")
|
||||
|
||||
// 真实存活源 + 重启通道:daemon 心跳语义由此修正(lastHB 只在确认存活时更新),
|
||||
// 重启动作不再空转——清理后以特殊退出码交给 guard/systemd 重建。
|
||||
restartCh := make(chan struct{}, 1)
|
||||
sup.SetHeartbeatSource(func(id types.AgentID) (time.Time, types.HealthStatus, error) {
|
||||
st := agent.GetKernelStatus()
|
||||
if st == nil {
|
||||
return time.Time{}, types.HealthDown, fmt.Errorf("no kernel status")
|
||||
}
|
||||
h := types.HealthHealthy
|
||||
if !st.LLM.Available {
|
||||
h = types.HealthDegraded
|
||||
}
|
||||
return time.Now(), h, nil
|
||||
})
|
||||
sup.SetRestartHandler(func(id types.AgentID) {
|
||||
select {
|
||||
case restartCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
return restartCh
|
||||
}
|
||||
|
||||
// startHeartbeat 每 5s 触碰 <data>/heartbeat(guard 据此判定 worker 存活/卡死),返回停止函数。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startHeartbeat(dataDir string, ctx context.Context) func() {
|
||||
// 心跳:每 5s 触碰 <data>/heartbeat,guard 据此判定工作进程是否存活/卡死
|
||||
hbPath := filepath.Join(dataDir, "heartbeat")
|
||||
hbStop := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
writeHB := func() {
|
||||
if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||
fmt.Fprintf(f, "t=%d\n", time.Now().Unix())
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
writeHB()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
writeHB()
|
||||
case <-hbStop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() { close(hbStop) }
|
||||
}
|
||||
|
||||
// waitForShutdown 阻塞至 SIGINT/SIGTERM 或 supervisor 请求重启,然后按原 main 的顺序清理,需要重建时以退出码交回 guard。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func waitForShutdown(ctx context.Context, dataDir string, restartCh chan struct{}, stopHeartbeat func(),
|
||||
pluginReg *plugin.Registry, trk *tracker.Tracker, cfgReg *internalConfig.ConfigRegistry, sup *supervisor.Daemon) {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
restartRequested := false
|
||||
select {
|
||||
case <-sigCh:
|
||||
log.Printf("[homed] shutting down...")
|
||||
case <-restartCh:
|
||||
restartRequested = true
|
||||
log.Printf("[homed] restart requested, shutting down cleanly then exiting with code %d", exitRestartRequested)
|
||||
}
|
||||
|
||||
stopHeartbeat()
|
||||
pluginReg.StopAll()
|
||||
if trk != nil {
|
||||
trk.Stop()
|
||||
}
|
||||
if err := cfgReg.Flush(); err != nil {
|
||||
log.Printf("[homed] flush config: %v", err)
|
||||
}
|
||||
sup.Shutdown()
|
||||
log.Printf("[homed] stopped")
|
||||
|
||||
if restartRequested {
|
||||
os.Exit(exitRestartRequested)
|
||||
}
|
||||
}
|
||||
|
||||
// initLuaVM 起 Lua VM(LLM 协议适配);启动失败只告警,cleanup 为 no-op。
|
||||
func initLuaVM(cfg *types.Config) (*luapkg.VM, func()) {
|
||||
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
|
||||
if err := luaVM.Start(); err != nil {
|
||||
log.Printf("[homed] warning: lua vm init failed: %v", err)
|
||||
return luaVM, func() {}
|
||||
}
|
||||
return luaVM, luaVM.Stop
|
||||
}
|
||||
|
||||
// initSupervisor 起守护管理(代理生命周期管理);起不来是致命错误。
|
||||
func initSupervisor(cfg *types.Config) *supervisor.Daemon {
|
||||
sup := supervisor.New(cfg)
|
||||
if err := sup.Start(); err != nil {
|
||||
log.Fatalf("start supervisor: %v", err)
|
||||
}
|
||||
return sup
|
||||
}
|
||||
|
||||
// initTracker 起 overlayfs 变更追踪;无 overlayfs 支持时降级为非致命告警。
|
||||
func initTracker(cfg *types.Config, agentWorkDir string) *tracker.Tracker {
|
||||
trk := tracker.NewTracker(cfg.Daemon.DataDir, agentWorkDir,
|
||||
tracker.WithKeepChangesets(100),
|
||||
tracker.WithMaxChangesetAge(30*24*time.Hour),
|
||||
)
|
||||
if err := trk.Init(); err != nil {
|
||||
log.Printf("[homed] warning: tracker init: %v", err)
|
||||
} else {
|
||||
if err := trk.Start(); err != nil {
|
||||
log.Printf("[homed] warning: tracker mount overlay: %v (non-fatal: no overlayfs support?)", err)
|
||||
} else {
|
||||
log.Printf("[homed] change tracker active at %s", trk.MergeDir())
|
||||
}
|
||||
}
|
||||
return trk
|
||||
}
|
||||
|
||||
// initKernelAPI 建内核与插件之间的两个通道:IOManager(IO 抽象层)+ EventBus(事件总线)。
|
||||
func initKernelAPI() (*agentIO.IOManager, *events.Bus) {
|
||||
iom := agentIO.NewIOManager()
|
||||
evBus := events.NewBus()
|
||||
log.Printf("[homed] kernel API ready: IOManager + EventBus")
|
||||
return iom, evBus
|
||||
}
|
||||
|
||||
// initTextMemory 起文本记忆;启动失败只告警,cleanup 为 no-op。
|
||||
func initTextMemory(cfg *types.Config) (*text.Memory, func()) {
|
||||
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||
if err := textMem.Start(); err != nil {
|
||||
log.Printf("[homed] warning: text memory start: %v", err)
|
||||
return textMem, func() {}
|
||||
}
|
||||
log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||
return textMem, textMem.Stop
|
||||
}
|
||||
|
||||
// resolveBaseAPIKey 解析兜底 API key:配置项 > LLM_API_KEY > DEEPSEEK_API_KEY。
|
||||
func resolveBaseAPIKey(cfg *types.Config) string {
|
||||
apiKey := cfg.LLM.APIKey
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("LLM_API_KEY")
|
||||
}
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("DEEPSEEK_API_KEY")
|
||||
}
|
||||
return apiKey
|
||||
}
|
||||
|
||||
// wirePluginSDK 把内核各面注入每个插件的 PluginSDK(阶段6 将替换遗留的 util.Configure)。
|
||||
func wirePluginSDK(pluginReg *plugin.Registry, luaVM *luapkg.VM, baseAPIKey string, sup *supervisor.Daemon,
|
||||
trk *tracker.Tracker, cfg *types.Config, stageHost *agentCore.StageHost, memIdx *memory.Indexer,
|
||||
agent *agentCore.Agent) {
|
||||
pluginReg.SetLuaVM(luaVM)
|
||||
pluginReg.SetBaseAPIKey(baseAPIKey)
|
||||
pluginReg.SetSupervisor(supervisor.NewSDKAdapter(sup))
|
||||
pluginReg.SetTracker(trk)
|
||||
pluginReg.SetConfig(cfg)
|
||||
pluginReg.SetStageHost(stageHost)
|
||||
pluginReg.SetIndexer(memIdx)
|
||||
pluginReg.SetStatusProvider(agent)
|
||||
}
|
||||
|
||||
// resolveWebUIOverride 解析 webui 监听地址的覆盖值,空串表示不覆盖。
|
||||
//
|
||||
// 优先级:CLI --webui > 核心配置 webui.listen_addr(仅当它被改成非内置默认值)。
|
||||
// 两者都不给时由 webui 插件自己的 settings["addr"] 决定。
|
||||
//
|
||||
// 为什么不写成“内核在插件加载前 Set 插件 settings['addr']”:那时
|
||||
// config_webui 表还没建(表只在插件注册 def 时创建),PluginSettings.Set 的
|
||||
// INSERT 会失败而错误被忽略,随后插件 Start 里 RegisterDef 才建表并写入默认
|
||||
// :8080 —— 于是 CLI --webui 与 webui.listen_addr **一直是死配置**,
|
||||
// 无论怎么传都监听 :8080。覆盖值改由插件自己接收(webui.SetListenOverride)。
|
||||
func resolveWebUIOverride(cfgReg *internalConfig.ConfigRegistry, httpAddr string) string {
|
||||
if strings.TrimSpace(httpAddr) != "" {
|
||||
return strings.TrimSpace(httpAddr)
|
||||
}
|
||||
// webui.listen_addr 的播种默认值就是 ":8080";与默认值相同视为“未配置”,
|
||||
// 否则会把用户在设置页里改过的插件 addr 顶掉。
|
||||
if v := strings.TrimSpace(cfgReg.GetString("webui.listen_addr", ":8080")); v != "" && v != ":8080" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// options 是 worker 的命令行参数。
|
||||
type options struct {
|
||||
dataDir string
|
||||
httpAddr string
|
||||
cliSocket string
|
||||
role string
|
||||
boot string
|
||||
}
|
||||
|
||||
// parseFlags 解析命令行参数。
|
||||
func parseFlags() options {
|
||||
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
|
||||
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
|
||||
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
|
||||
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)")
|
||||
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
|
||||
flag.Parse()
|
||||
return options{dataDir: *dataDir, httpAddr: *httpAddr, cliSocket: *cliSocket, role: *role, boot: *boot}
|
||||
}
|
||||
|
||||
// compactConfigDB 在空闲页够多时压缩配置库;失败只告警(不影响启动)。
|
||||
//
|
||||
// 触发条件(见 internal/config.MaybeCompact):空闲页 >= 1MB 且占页数 >= 25%。
|
||||
// 放在插件加载之后调用——迁移/清理大值发生在插件 Start 里,之前调用没有意义。
|
||||
func compactConfigDB(cfgReg *internalConfig.ConfigRegistry) {
|
||||
before := int64(-1)
|
||||
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
|
||||
before = st.Size()
|
||||
}
|
||||
done, err := cfgReg.MaybeCompact(1<<20, 0.25)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: 配置库压缩失败: %v", err)
|
||||
return
|
||||
}
|
||||
if !done {
|
||||
return
|
||||
}
|
||||
after := before
|
||||
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
|
||||
after = st.Size()
|
||||
}
|
||||
log.Printf("[homed] 配置库已压缩: %d -> %d 字节", before, after)
|
||||
}
|
||||
@ -2,623 +2,184 @@ 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/pipeline"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter"
|
||||
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/recovery"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||
|
||||
// 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。
|
||||
// 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/chineseclip"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl"
|
||||
)
|
||||
|
||||
// main 是 worker 进程的启动序列。
|
||||
//
|
||||
// 形状约定:本函数只保留「顺序编排 + 就地交接」——
|
||||
// - 每个阶段一行调用,参数即该阶段的全部依赖(依赖顺序即调用顺序);
|
||||
// - 阶段实现体在同包 bootstrap.go,与这里的调用一一对应;
|
||||
// - 需要逆序释放的资源由阶段函数返回 cleanup,在**原位** defer 注册,
|
||||
// 因此释放顺序与拆分前完全一致。
|
||||
func main() {
|
||||
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()
|
||||
// 平台门放在最前面:比 flag 解析还早,因为原生 Windows 上根本不应进入任何
|
||||
// 初始化路径(会去建共享段、拉插件进程)。理由与 WSL 指引见
|
||||
// platform_windows.go。
|
||||
requireSupportedPlatform()
|
||||
|
||||
opt := parseFlags()
|
||||
|
||||
// 父守护模式:只负责拉起/守护 worker,不初始化 agent 内核
|
||||
if *role == "guard" {
|
||||
runGuard(resolveDataDir(*dataDir))
|
||||
if opt.role == "guard" {
|
||||
runGuard(resolveDataDir(opt.dataDir))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[homed] role=agent boot=%s", *boot)
|
||||
log.Printf("[homed] role=agent boot=%s", opt.boot)
|
||||
|
||||
if *dataDir == "" {
|
||||
*dataDir = resolveDataDir(*dataDir)
|
||||
if opt.dataDir == "" {
|
||||
opt.dataDir = resolveDataDir(opt.dataDir)
|
||||
}
|
||||
|
||||
if *cliSocket == "" {
|
||||
*cliSocket = filepath.Join(*dataDir, "cli.sock")
|
||||
if opt.cliSocket == "" {
|
||||
opt.cliSocket = filepath.Join(opt.dataDir, "cli.sock")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
logDir := setupLogging(opt.dataDir)
|
||||
|
||||
log.Printf("[homed] starting %s", meta.FullVersion())
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
agentWorkDir := ensureDataDirs(opt.dataDir)
|
||||
|
||||
// ========================================================================
|
||||
// 基础设施层:记忆、技能
|
||||
// ========================================================================
|
||||
// ---- 基础设施层:记忆、技能 ----
|
||||
|
||||
memDB, err := memory.NewGraphDB(filepath.Join(*dataDir, "memory", "graph.db"))
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: memory init failed: %v", err)
|
||||
memDB = nil
|
||||
} else {
|
||||
log.Printf("[homed] graph memory initialized")
|
||||
}
|
||||
if memDB != nil {
|
||||
defer memDB.Close()
|
||||
}
|
||||
mem, closeMem := initMemoryStack(opt.dataDir)
|
||||
defer closeMem()
|
||||
|
||||
memIdx := memory.NewIndexer(memDB)
|
||||
memIdx.Sync() // 启动时立即同步,避免前30分钟空窗
|
||||
socialStore := social.New(memDB)
|
||||
// ---- 配置中心(SQLite 持久化,唯一配置源) ----
|
||||
|
||||
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"))
|
||||
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(opt.dataDir, "config.db"))
|
||||
defer cfgReg.Close()
|
||||
cfgReg.SeedDefaults(*dataDir)
|
||||
cfgReg.SeedDefaults(opt.dataDir)
|
||||
// LLM 配置写前留档(config_set 写 core.llm.* 前自动快照),guard 恢复用基线
|
||||
cfgReg.SetLLMSnapshotFile(filepath.Join(*dataDir, "llm_snapshot.json"))
|
||||
cfgReg.SetLLMSnapshotFile(filepath.Join(opt.dataDir, "llm_snapshot.json"))
|
||||
cfg := cfgReg.ToConfig()
|
||||
|
||||
// 共享词嵌入:蒸馏提取(Phase 3 TransE 验证)与 Agent 上下文复用同一实例,
|
||||
// 避免同一模型被二次加载(约 200k×300 维 ≈ 数百 MB 内存)。
|
||||
embedder := memory.NewStaticEmbedder(strings.Split(cfgReg.GetString("core.agent.embedding_model_path", ""), ",")...)
|
||||
distiller.SetEmbedder(embedder)
|
||||
mem.distiller.SetEmbedder(embedder)
|
||||
|
||||
// ========================================================================
|
||||
// Lua VM(LLM 协议适配)
|
||||
// ========================================================================
|
||||
// ---- Lua VM(LLM 协议适配) ----
|
||||
|
||||
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
|
||||
if err := luaVM.Start(); err != nil {
|
||||
log.Printf("[homed] warning: lua vm init failed: %v", err)
|
||||
} else {
|
||||
defer luaVM.Stop()
|
||||
}
|
||||
luaVM, closeLuaVM := initLuaVM(cfg)
|
||||
defer closeLuaVM()
|
||||
|
||||
// ========================================================================
|
||||
// 守护管理(代理生命周期管理)
|
||||
// ========================================================================
|
||||
// ---- 守护管理(代理生命周期管理) ----
|
||||
|
||||
sup := supervisor.New(cfg)
|
||||
if err := sup.Start(); err != nil {
|
||||
log.Fatalf("start supervisor: %v", err)
|
||||
}
|
||||
sup := initSupervisor(cfg)
|
||||
|
||||
// ========================================================================
|
||||
// 变更追踪(overlayfs)
|
||||
// ========================================================================
|
||||
// ---- 变更追踪(overlayfs) ----
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
trk := initTracker(cfg, agentWorkDir)
|
||||
|
||||
// ========================================================================
|
||||
// 内核 API:IOManager(IO 抽象层) + EventBus(事件总线)
|
||||
// 所有插件通过这两个通道与核心交互
|
||||
// ========================================================================
|
||||
|
||||
iom := agentIO.NewIOManager()
|
||||
evBus := events.NewBus()
|
||||
log.Printf("[homed] kernel API ready: IOManager + EventBus")
|
||||
iom, evBus := initKernelAPI()
|
||||
|
||||
// ========================================================================
|
||||
// 文本记忆 + 记忆蒸馏管线
|
||||
// ========================================================================
|
||||
// ---- 文本记忆 + 记忆蒸馏管线 ----
|
||||
|
||||
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||
if err := textMem.Start(); err != nil {
|
||||
log.Printf("[homed] warning: text memory start: %v", err)
|
||||
} else {
|
||||
defer textMem.Stop()
|
||||
log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||
}
|
||||
textMem, closeTextMem := initTextMemory(cfg)
|
||||
defer closeTextMem()
|
||||
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
defer stop()
|
||||
|
||||
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)
|
||||
startMemoryCandidateConsumer(ctx, iom, textMem, mem.db, mem.distiller)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// ---- LLM Provider 管理(多源,通过 Lua 适配器协议转换) ----
|
||||
|
||||
if input != "" && memDB != nil {
|
||||
distiller.Append("agent", "user", input)
|
||||
}
|
||||
if response != "" && memDB != nil {
|
||||
distiller.Append("agent", "assistant", response)
|
||||
}
|
||||
baseAPIKey := resolveBaseAPIKey(cfg)
|
||||
|
||||
// 工具输出接入蒸馏管线
|
||||
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,
|
||||
}, 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)
|
||||
}
|
||||
providerMgr := initLLMProviders(cfg, luaVM, baseAPIKey)
|
||||
provider := providerMgr.Default()
|
||||
|
||||
// L1 failback:受限 worker 启动即跑恢复梯子(probe→还原DNS/proxy→还原config+ReloadFromConfig→probe),
|
||||
// 结果以退出码 exitRecovered=43 / exitRecoveryFailed=44 交回 guard,不进入主 agent 循环。
|
||||
if *boot == "failback" {
|
||||
runFailbackRecovery(*dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
|
||||
if opt.boot == "failback" {
|
||||
runFailbackRecovery(opt.dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 文档记忆 + 知识库
|
||||
// ========================================================================
|
||||
// ---- 文档记忆 + 知识库 ----
|
||||
|
||||
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"))
|
||||
if err := docStore.Start(); err != nil {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
docStore, closeDocStore := initDocStore(cfg)
|
||||
defer closeDocStore()
|
||||
|
||||
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()))
|
||||
}
|
||||
mediaStore, closeMediaStore := initMediaStore(cfgReg, cfg)
|
||||
defer closeMediaStore()
|
||||
|
||||
// ========================================================================
|
||||
// 人格设定
|
||||
// ========================================================================
|
||||
multimodalSpace, mmProviderName, mmErr, closeMultimodal := initMultimodalSpace(cfgReg)
|
||||
defer closeMultimodal()
|
||||
|
||||
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||
personality, err := agentPkg.LoadPersonality(personalPath)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: load personality: %v", err)
|
||||
}
|
||||
if personality != nil && personality.Content != "" {
|
||||
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
|
||||
}
|
||||
ks := initKnowledgeStore(cfg)
|
||||
|
||||
// ========================================================================
|
||||
// 阶段管道(StageHost)+ 插件系统(Registry)
|
||||
// ========================================================================
|
||||
// ---- 人格设定 ----
|
||||
|
||||
stageHost := agentCore.NewStageHost()
|
||||
personality := loadPersonality(cfg, cfgReg)
|
||||
|
||||
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)
|
||||
// ---- 阶段管道(StageHost)+ 插件系统(Registry) ----
|
||||
|
||||
// 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)
|
||||
stageHost, pluginReg := newStageAndRegistry(cfg, cfgReg, iom, evBus, mem.db, textMem,
|
||||
docStore, mediaStore, ks, providerMgr, opt.dataDir)
|
||||
|
||||
// ========================================================================
|
||||
// Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider)
|
||||
// ========================================================================
|
||||
// ---- Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider) ----
|
||||
|
||||
defaultPrompt := `你是 HomeAgent,一个持续运行的个人管家。
|
||||
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。
|
||||
如需切换回复通道,使用 output_set_channel。
|
||||
如需异步发送消息或通知,使用 output_send 指定通道和内容。
|
||||
使用 output_list_channels 查看可用通道及其能力。
|
||||
agent := newMainAgent(cfg, cfgReg, provider, providerMgr, iom, mem.db, mem.indexer, trk,
|
||||
docStore, ks, mem.social, textMem, mediaStore, personality, pluginReg, embedder,
|
||||
multimodalSpace, mmProviderName, mmErr, stageHost, evBus)
|
||||
|
||||
可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具:
|
||||
- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实)
|
||||
- knowledge_* — 知识库(查阅预设知识文档)
|
||||
- doc_* — 文档记忆(近期对话的存档,查询后自动清除)
|
||||
- person_* — 人物特质与社交关系网
|
||||
- llm_* — LLM 源管理(列出/切换模型提供商)
|
||||
- output_* — 输出通道管理(切换/发送消息)
|
||||
- timer_set — 设置定时提醒
|
||||
- plgreload — 热重载插件
|
||||
- spawn_child — 生成子 Agent 执行独立任务
|
||||
- describe_image — 描述用户上传的图片
|
||||
- transcribe_audio — 转写用户上传的音频
|
||||
- ocr_image — 识别图片中的文字
|
||||
|
||||
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。
|
||||
|
||||
回复你的真实想法,用自然语言与用户交流。`
|
||||
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)
|
||||
wirePluginSDK(pluginReg, luaVM, baseAPIKey, sup, trk, cfg, stageHost, mem.indexer, agent)
|
||||
|
||||
// 为内置插件注入内核依赖(各插件通过 init() 自注册工厂)
|
||||
cli.DefaultSocket = *cliSocket
|
||||
// webui 插件作为内置插件经 Registry 启动,读取自身 settings["addr"](默认 :8080)。
|
||||
// 保留 CLI --webui 与 webui.listen_addr 配置对监听地址的覆盖。
|
||||
webuiListenAddr := *httpAddr
|
||||
if webuiListenAddr == "" {
|
||||
webuiListenAddr = cfgReg.GetString("webui.listen_addr", ":8080")
|
||||
}
|
||||
if ps := cfgReg.PluginConfig("webui"); ps != nil {
|
||||
if v, _ := ps.Get("addr"); v == nil {
|
||||
_ = ps.Set("addr", webuiListenAddr)
|
||||
}
|
||||
}
|
||||
cli.DefaultSocket = opt.cliSocket
|
||||
// webui 监听地址覆盖:必须在 loadPlugins 之前设置,插件 Start 时会读它。
|
||||
webui.SetListenOverride(resolveWebUIOverride(cfgReg, opt.httpAddr))
|
||||
|
||||
// ========================================================================
|
||||
// 依存句法分析器(内嵌 ONNX 模型 / 规则引擎)
|
||||
// ========================================================================
|
||||
// ---- 依存句法分析器(内嵌 ONNX 模型 / 规则引擎) ----
|
||||
|
||||
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)
|
||||
}
|
||||
initONNXParser(cfg, cfgReg)
|
||||
|
||||
// Auto-create plugins directory (without hardcoding plugin names)
|
||||
os.MkdirAll(cfg.Plugin.Dir, 0755)
|
||||
loadPlugins(cfg, cfgReg, pluginReg, stageHost, opt.boot, opt.dataDir)
|
||||
|
||||
// failback 受限启动:仅装载 failback 插件集(webfetch/files/cmd 为内核内置,
|
||||
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论)
|
||||
if *boot == "failback" {
|
||||
list := cfgReg.GetString("core.agent.failback_plugins", "webui,pluginmgr,recoverydiag")
|
||||
// 优先使用 guard.yaml 经过 recovery 任务下发的插件集(guard 是 failback 权威)
|
||||
if task, terr := recovery.LoadTask(recovery.TaskPath(*dataDir)); terr == nil && len(task.Plugins()) > 0 {
|
||||
list = strings.Join(task.Plugins(), ",")
|
||||
}
|
||||
var names []string
|
||||
for _, s := range strings.Split(list, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
pluginReg.SetLoadAllowlist(names)
|
||||
log.Printf("[homed] failback boot: plugin allowlist = %v", names)
|
||||
}
|
||||
// 插件加载完成后再回收空闲页:大值(如老版聊天记录)可能在这一步被搬走/删除,
|
||||
// 而 SQLite 的 DELETE 不会缩小文件。
|
||||
compactConfigDB(cfgReg)
|
||||
|
||||
// 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())
|
||||
stopRuntime := startAgentRuntime(cfgReg, pluginReg, agent, logDir, ctx)
|
||||
defer stopRuntime()
|
||||
|
||||
// 日志管理:层级压缩 + 保留策略
|
||||
logManager := logpkg.NewManager(logDir, cfgReg)
|
||||
go logManager.Start(ctx)
|
||||
defer logManager.Stop()
|
||||
_, closeIPC := startIPCServer(opt.dataDir, opt.boot, agent)
|
||||
defer closeIPC()
|
||||
|
||||
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:
|
||||
}
|
||||
})
|
||||
restartCh := startSupervisorRuntime(sup, trk, agent)
|
||||
|
||||
log.Printf("[homed] main agent started, model=%s base=%s sources=%d adapters=%d",
|
||||
cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters()))
|
||||
log.Printf("[homed] kernel ready, waiting for plugin IO...")
|
||||
|
||||
// ========================================================================
|
||||
// 等待退出信号
|
||||
// ========================================================================
|
||||
// ---- 等待退出信号 ----
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// 心跳:每 5s 触碰 <data>/heartbeat,guard 据此判定工作进程是否存活/卡死
|
||||
hbPath := filepath.Join(*dataDir, "heartbeat")
|
||||
hbStop := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
writeHB := func() {
|
||||
if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||
fmt.Fprintf(f, "t=%d\n", time.Now().Unix())
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
writeHB()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
writeHB()
|
||||
case <-hbStop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
restartRequested := false
|
||||
select {
|
||||
case <-sigCh:
|
||||
log.Printf("[homed] shutting down...")
|
||||
case <-restartCh:
|
||||
restartRequested = true
|
||||
log.Printf("[homed] restart requested, shutting down cleanly then exiting with code %d", exitRestartRequested)
|
||||
}
|
||||
|
||||
close(hbStop)
|
||||
pluginReg.StopAll()
|
||||
if trk != nil {
|
||||
trk.Stop()
|
||||
}
|
||||
if err := cfgReg.Flush(); err != nil {
|
||||
log.Printf("[homed] flush config: %v", err)
|
||||
}
|
||||
sup.Shutdown()
|
||||
log.Printf("[homed] stopped")
|
||||
|
||||
if restartRequested {
|
||||
os.Exit(exitRestartRequested)
|
||||
}
|
||||
stopHeartbeat := startHeartbeat(opt.dataDir, ctx)
|
||||
waitForShutdown(ctx, opt.dataDir, restartCh, stopHeartbeat, pluginReg, trk, cfgReg, sup)
|
||||
}
|
||||
|
||||
9
cmd/homed/platform_other.go
Normal file
9
cmd/homed/platform_other.go
Normal file
@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
// requireSupportedPlatform 在受支持的平台上不做任何事。
|
||||
//
|
||||
// 平台策略见 platform_windows.go:只有 homed 放弃 Windows 原生支持
|
||||
// (插件体系依赖 fd 继承与共享内存段内偏移),Windows 用户走 WSL2。
|
||||
func requireSupportedPlatform() {}
|
||||
44
cmd/homed/platform_windows.go
Normal file
44
cmd/homed/platform_windows.go
Normal file
@ -0,0 +1,44 @@
|
||||
//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,6 +17,36 @@ func randomSecret(n int) string {
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// must 让失败真正停下来。
|
||||
//
|
||||
// 这里曾经把所有 db.Exec 的返回值丢掉,配合 CGO_ENABLED=0 构建(go-sqlite3
|
||||
// 退化成静态桩),得到的是一个**完全静默的空操作**:打印凭据、退出码 0、
|
||||
// config.db 里一个字节都没写。调用方(安装脚本)无法区分成败,用户装完
|
||||
// 照着 credentials.txt 登录必然失败。
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// verify 回读刚写入的值。
|
||||
//
|
||||
// 只看 Exec 有没有报错不够:驱动被换掉(如上面的桩)、路径不对、写入被丢弃,
|
||||
// 都可能返回 nil 而什么都没落下。这里把真实落盘的值读回来,与预期逐一比对,
|
||||
// 不一致就非零退出——"初始化脚本说自己成功了"必须由数据库内容佐证。
|
||||
func verify(db *sql.DB, table, key, want string) {
|
||||
var got string
|
||||
if err := db.QueryRow(fmt.Sprintf(`SELECT value FROM %s WHERE key = ?`, table), key).Scan(&got); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 回读 %s.%s 失败: %v\n", table, key, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if got != want {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: %s.%s 与写入值不一致(读回 %q)\n", table, key, got)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
dataDir := flag.String("data", "", "data directory")
|
||||
webuiUsername := flag.String("username", "admin", "webui username")
|
||||
@ -33,16 +63,24 @@ func main() {
|
||||
|
||||
dbPath := *dataDir + "/config.db"
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "open db: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
must(err)
|
||||
defer db.Close()
|
||||
|
||||
db.Exec("PRAGMA journal_mode=WAL")
|
||||
// 尽早验证数据库真的可用:sql.Open 是惰性的,不碰一次不会暴露驱动问题。
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 打开数据库 %s 失败: %v\n", dbPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
|
||||
db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, "webui.listen_addr", ":8080")
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 创建 config 表失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
const listenAddr = ":8080"
|
||||
if _, err := db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, "webui.listen_addr", listenAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 写入 webui.listen_addr 失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
pw := *webuiPassword
|
||||
if pw == "" {
|
||||
@ -53,13 +91,28 @@ func main() {
|
||||
apiKey = randomSecret(16)
|
||||
}
|
||||
|
||||
pt := "config_webui"
|
||||
db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, pt))
|
||||
const pt = "config_webui"
|
||||
if _, err := db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, pt)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 创建 %s 表失败: %v\n", pt, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ws := fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, pt)
|
||||
db.Exec(ws, "api_key", apiKey)
|
||||
db.Exec(ws, "username", *webuiUsername)
|
||||
db.Exec(ws, "password", pw)
|
||||
db.Exec(ws, "session_ttl_hours", "24")
|
||||
for _, kv := range [][2]string{
|
||||
{"api_key", apiKey},
|
||||
{"username", *webuiUsername},
|
||||
{"password", pw},
|
||||
{"session_ttl_hours", "24"},
|
||||
} {
|
||||
if _, err := db.Exec(ws, kv[0], kv[1]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 写入 %s.%s 失败: %v\n", pt, kv[0], err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
verify(db, pt, "api_key", apiKey)
|
||||
verify(db, pt, "username", *webuiUsername)
|
||||
verify(db, pt, "password", pw)
|
||||
verify(db, "config", "webui.listen_addr", listenAddr)
|
||||
|
||||
fmt.Printf("API_KEY=%s\n", apiKey)
|
||||
fmt.Printf("WEBUI_USERNAME=%s\n", *webuiUsername)
|
||||
|
||||
521
cmd/mock-server/main.go
Normal file
521
cmd/mock-server/main.go
Normal file
@ -0,0 +1,521 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ===== 模拟数据 =====
|
||||
|
||||
type Status struct {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
}
|
||||
|
||||
type Kernel struct {
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type Setting struct {
|
||||
Settings map[string]interface{} `json:"settings"`
|
||||
Meta map[string]interface{} `json:"meta"`
|
||||
Plugins []string `json:"plugins"`
|
||||
PluginMeta map[string]interface{} `json:"plugin_meta"`
|
||||
DisabledPlugins []string `json:"disabled_plugins"`
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Builtin bool `json:"builtin"`
|
||||
}
|
||||
|
||||
type PluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Builtin bool `json:"builtin"`
|
||||
Tools []PluginTool `json:"tools"`
|
||||
}
|
||||
|
||||
type PluginTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type Adapter struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type MemoryItem struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
Authorized bool `json:"authorized"`
|
||||
Online bool `json:"online"`
|
||||
Caps []string `json:"caps"`
|
||||
}
|
||||
|
||||
// ===== SSE 管理器 =====
|
||||
|
||||
type SSEManager struct {
|
||||
mu sync.RWMutex
|
||||
clients map[chan string]bool
|
||||
}
|
||||
|
||||
func NewSSEManager() *SSEManager {
|
||||
return &SSEManager{clients: make(map[chan string]bool)}
|
||||
}
|
||||
|
||||
func (m *SSEManager) Add(ch chan string) {
|
||||
m.mu.Lock()
|
||||
m.clients[ch] = true
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *SSEManager) Remove(ch chan string) {
|
||||
m.mu.Lock()
|
||||
delete(m.clients, ch)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *SSEManager) Broadcast(eventType, data string) {
|
||||
msg := fmt.Sprintf("event: %s\ndata: %s\n\n", eventType, data)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for ch := range m.clients {
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== HTTP 处理器 =====
|
||||
|
||||
type MockServer struct {
|
||||
startedAt time.Time
|
||||
sse *SSEManager
|
||||
mu sync.Mutex
|
||||
plugins []Plugin
|
||||
adapters []Adapter
|
||||
devices []Device
|
||||
settings map[string]interface{}
|
||||
}
|
||||
|
||||
func NewMockServer() *MockServer {
|
||||
now := time.Now()
|
||||
return &MockServer{
|
||||
startedAt: now,
|
||||
sse: NewSSEManager(),
|
||||
plugins: []Plugin{
|
||||
{Name: "core", Description: "核心插件", Version: "1.0.0", Enabled: true, Builtin: true},
|
||||
{Name: "remotedevice", Description: "远程设备管理", Version: "0.9.0", Enabled: true, Builtin: true},
|
||||
{Name: "webui", Description: "Web 用户界面", Version: "0.9.0", Enabled: true, Builtin: true},
|
||||
{Name: "knowledge", Description: "知识库管理", Version: "0.5.0", Enabled: true, Builtin: false},
|
||||
},
|
||||
adapters: []Adapter{
|
||||
{Name: "openai", Type: "llm", Enabled: true},
|
||||
{Name: "siliconflow", Type: "llm", Enabled: true},
|
||||
},
|
||||
devices: []Device{
|
||||
{DeviceID: "gui-test-local", Name: "GUI 测试设备", Authorized: true, Online: true, Caps: []string{"status", "cmdrun", "deviceinfo"}},
|
||||
},
|
||||
settings: map[string]interface{}{
|
||||
"language": "zh-CN",
|
||||
"theme": "dark",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 中间件:CORS + API Key 校验
|
||||
func (s *MockServer) middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization, Cookie")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(200)
|
||||
return
|
||||
}
|
||||
|
||||
// API Key 校验(可选)
|
||||
// apiKey := r.Header.Get("X-API-Key")
|
||||
// if apiKey == "" {
|
||||
// http.Error(w, "unauthorized", 401)
|
||||
// return
|
||||
// }
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MockServer) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, Status{
|
||||
Status: "running",
|
||||
Version: "0.9.0",
|
||||
StartedAt: s.startedAt.Format(time.RFC3339),
|
||||
Uptime: int64(time.Since(s.startedAt).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MockServer) handleKernel(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, Kernel{
|
||||
Model: "sensenova-6.8-flash-lite",
|
||||
Provider: "siliconflow",
|
||||
Status: "ready",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MockServer) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
var updates map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err == nil {
|
||||
s.mu.Lock()
|
||||
for k, v := range updates {
|
||||
s.settings[k] = v
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "saved"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, Setting{
|
||||
Settings: s.settings,
|
||||
Meta: map[string]interface{}{
|
||||
"version": "0.9.0",
|
||||
"build": "mock-20260823",
|
||||
},
|
||||
Plugins: []string{"core", "remotedevice", "webui", "knowledge"},
|
||||
PluginMeta: map[string]interface{}{
|
||||
"core": map[string]interface{}{"version": "1.0.0"},
|
||||
"remotedevice": map[string]interface{}{"version": "0.9.0"},
|
||||
"webui": map[string]interface{}{"version": "0.9.0"},
|
||||
"knowledge": map[string]interface{}{"version": "0.5.0"},
|
||||
},
|
||||
DisabledPlugins: []string{},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MockServer) handlePlugins(w http.ResponseWriter, r *http.Request) {
|
||||
// 获取路径中的插件名
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/plugins")
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
|
||||
if path == "/reload" && r.Method == "POST" {
|
||||
writeJSON(w, map[string]string{"status": "reloaded"})
|
||||
return
|
||||
}
|
||||
|
||||
if path == "" && r.Method == "GET" {
|
||||
writeJSON(w, s.plugins)
|
||||
return
|
||||
}
|
||||
|
||||
if path == "" && r.Method == "POST" {
|
||||
writeJSON(w, map[string]string{"status": "installed"})
|
||||
return
|
||||
}
|
||||
|
||||
// /api/v1/plugins/:name
|
||||
if strings.Contains(path, "/") {
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
|
||||
if len(parts) >= 1 {
|
||||
name := parts[0]
|
||||
if len(parts) >= 2 {
|
||||
action := parts[1]
|
||||
if action == "disable" && r.Method == "POST" {
|
||||
s.mu.Lock()
|
||||
for i := range s.plugins {
|
||||
if s.plugins[i].Name == name {
|
||||
s.plugins[i].Enabled = false
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, map[string]string{"status": "disabled"})
|
||||
return
|
||||
}
|
||||
if action == "enable" && r.Method == "POST" {
|
||||
s.mu.Lock()
|
||||
for i := range s.plugins {
|
||||
if s.plugins[i].Name == name {
|
||||
s.plugins[i].Enabled = true
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, map[string]string{"status": "enabled"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/plugins/:name
|
||||
writeJSON(w, PluginInfo{
|
||||
Name: name,
|
||||
Description: name + " 插件描述",
|
||||
Version: "0.9.0",
|
||||
Enabled: true,
|
||||
Builtin: true,
|
||||
Tools: []PluginTool{
|
||||
{Name: name + "_tool1", Description: name + " 工具1"},
|
||||
{Name: name + "_tool2", Description: name + " 工具2"},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (s *MockServer) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, []ChatMessage{
|
||||
{Role: "user", Content: "你好"},
|
||||
{Role: "assistant", Content: "你好!我是 HomeAgent,有什么可以帮你的?"},
|
||||
{Role: "user", Content: "测试消息"},
|
||||
{Role: "assistant", Content: "这是模拟后端的测试回复,GUI 连接正常 ✅"},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MockServer) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
// 模拟后端接收消息,通过 SSE 推流
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// agent_start
|
||||
s.sse.Broadcast("agent_output", `{"type":"agent_start","payload":{"agent":"mock"}}`)
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// tool_call
|
||||
s.sse.Broadcast("agent_output", `{"type":"tool_call","payload":{"tool":"mock_tool","args":{},"id":"call_001"}}`)
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// channel_output
|
||||
s.sse.Broadcast("agent_output", `{"type":"channel_output","payload":{"kind":"channel_output","channel":"mock","content":"这是一条来自模拟后端的测试回复。\n\n- 模拟后端状态: running\n- 版本: 0.9.0\n- 连接测试: ✅ 成功\n\nGUI 所有功能验证正常!"}}`)
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// agent_end
|
||||
s.sse.Broadcast("agent_output", `{"type":"agent_end","payload":{"agent":"mock"}}`)
|
||||
}()
|
||||
|
||||
writeJSON(w, map[string]string{"status": "queued", "id": "mock_" + time.Now().Format("150405")})
|
||||
return
|
||||
}
|
||||
http.Error(w, "method not allowed", 405)
|
||||
}
|
||||
|
||||
func (s *MockServer) handleChatEvents(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
ch := make(chan string, 100)
|
||||
s.sse.Add(ch)
|
||||
defer s.sse.Remove(ch)
|
||||
|
||||
// 发送初始连接成功事件
|
||||
fmt.Fprintf(w, "event: connected\ndata: {\"status\":\"connected\"}\n\n")
|
||||
w.(http.Flusher).Flush()
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg := <-ch:
|
||||
fmt.Fprint(w, msg)
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MockServer) handleMemoryGraph(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{
|
||||
{"id": "1", "label": "HomeAgent", "group": "system"},
|
||||
{"id": "2", "label": "GUI 测试", "group": "user"},
|
||||
},
|
||||
"edges": []map[string]interface{}{
|
||||
{"from": "1", "to": "2", "label": "connected"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MockServer) handleMemory(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, []MemoryItem{
|
||||
{ID: "m1", Content: "这是模拟内存中的测试数据", Time: time.Now().Format(time.RFC3339)},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MockServer) handleMemoryContext(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"context": "模拟上下文:用户正在测试 GUI 功能",
|
||||
"items": []MemoryItem{},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MockServer) handleKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
writeJSON(w, map[string]string{"status": "saved"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, []map[string]interface{}{
|
||||
{"id": "k1", "title": "模拟知识条目1", "content": "这是模拟知识库的测试内容"},
|
||||
{"id": "k2", "title": "模拟知识条目2", "content": "GUI 功能验证测试数据"},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MockServer) handleTerminals(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, []map[string]interface{}{
|
||||
{"id": "t1", "name": "终端 1", "status": "running"},
|
||||
{"id": "t2", "name": "终端 2", "status": "idle"},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MockServer) handleCmdHistory(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, []map[string]interface{}{
|
||||
{"cmd": "echo hello", "time": time.Now().Add(-5 * time.Minute).Format(time.RFC3339)},
|
||||
{"cmd": "ls -la", "time": time.Now().Add(-10 * time.Minute).Format(time.RFC3339)},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MockServer) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/device")
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
|
||||
switch {
|
||||
case path == "/online" || path == "":
|
||||
writeJSON(w, s.devices)
|
||||
case path == "/auth" && r.Method == "POST":
|
||||
var req struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Authorized bool `json:"authorize"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
s.mu.Lock()
|
||||
for i := range s.devices {
|
||||
if s.devices[i].DeviceID == req.DeviceID {
|
||||
s.devices[i].Authorized = req.Authorized
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"authorized": true,
|
||||
"device_id": req.DeviceID,
|
||||
})
|
||||
case path == "/push" && r.Method == "POST":
|
||||
writeJSON(w, map[string]string{"status": "pushed"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MockServer) handleAdapters(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/adapters")
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
|
||||
switch {
|
||||
case path == "" && r.Method == "GET":
|
||||
writeJSON(w, s.adapters)
|
||||
case path == "" && r.Method == "POST":
|
||||
var a Adapter
|
||||
if err := json.NewDecoder(r.Body).Decode(&a); err == nil {
|
||||
s.mu.Lock()
|
||||
s.adapters = append(s.adapters, a)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "added"})
|
||||
case strings.Count(path, "/") == 1 && r.Method == "DELETE":
|
||||
name := strings.TrimPrefix(path, "/")
|
||||
s.mu.Lock()
|
||||
for i := range s.adapters {
|
||||
if s.adapters[i].Name == name {
|
||||
s.adapters = append(s.adapters[:i], s.adapters[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, map[string]string{"status": "deleted"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MockServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
// 简单返回 400,GUI 的 main.js 会尝试连接设备桥 WS
|
||||
// 这里只验证 HTTP 路由可达
|
||||
http.Error(w, "WebSocket upgrade required (mock server)", 400)
|
||||
}
|
||||
|
||||
// ===== 路由注册 =====
|
||||
|
||||
func (s *MockServer) registerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/status", s.handleStatus)
|
||||
mux.HandleFunc("/api/v1/kernel", s.handleKernel)
|
||||
mux.HandleFunc("/api/v1/settings", s.handleSettings)
|
||||
mux.HandleFunc("/api/v1/plugins", s.handlePlugins)
|
||||
mux.HandleFunc("/api/v1/plugins/", s.handlePlugins)
|
||||
mux.HandleFunc("/api/v1/chat/history", s.handleChatHistory)
|
||||
mux.HandleFunc("/api/v1/chat", s.handleChat)
|
||||
mux.HandleFunc("/api/v1/chat/events", s.handleChatEvents)
|
||||
mux.HandleFunc("/api/v1/memory/graph", s.handleMemoryGraph)
|
||||
mux.HandleFunc("/api/v1/memory", s.handleMemory)
|
||||
mux.HandleFunc("/api/v1/memory/context", s.handleMemoryContext)
|
||||
mux.HandleFunc("/api/v1/knowledge", s.handleKnowledge)
|
||||
mux.HandleFunc("/api/v1/terminals", s.handleTerminals)
|
||||
mux.HandleFunc("/api/v1/cmd/history", s.handleCmdHistory)
|
||||
mux.HandleFunc("/api/v1/device", s.handleDevices)
|
||||
mux.HandleFunc("/api/v1/device/", s.handleDevices)
|
||||
mux.HandleFunc("/api/v1/adapters", s.handleAdapters)
|
||||
mux.HandleFunc("/api/v1/adapters/", s.handleAdapters)
|
||||
mux.HandleFunc("/api/v1/device/ws", s.handleDeviceWS)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func main() {
|
||||
server := NewMockServer()
|
||||
mux := http.NewServeMux()
|
||||
server.registerRoutes(mux)
|
||||
|
||||
addr := ":9099"
|
||||
log.Printf("=== HomeAgent Mock Server ====")
|
||||
log.Printf("监听地址: http://0.0.0.0%s", addr)
|
||||
log.Printf("API 基础路径: http://0.0.0.0%s/api/v1/", addr)
|
||||
log.Printf("SSE 端点: http://0.0.0.0%s/api/v1/chat/events", addr)
|
||||
log.Printf("设备桥 WS: ws://0.0.0.0%s/api/v1/device/ws", addr)
|
||||
log.Printf("==============================")
|
||||
log.Fatal(http.ListenAndServe(addr, server.middleware(mux)))
|
||||
}
|
||||
472
cmd/mock-server/ws.go
Normal file
472
cmd/mock-server/ws.go
Normal file
@ -0,0 +1,472 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
|
||||
)
|
||||
|
||||
// ===== WebSocket 帧编码/解码(RFC 6455) =====
|
||||
|
||||
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
|
||||
type WSConn struct {
|
||||
conn net.Conn
|
||||
rw *bufio.ReadWriter
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func upgradeWS(w http.ResponseWriter, r *http.Request) (*WSConn, error) {
|
||||
if r.Header.Get("Upgrade") != "websocket" {
|
||||
http.Error(w, "not websocket", 400)
|
||||
return nil, fmt.Errorf("not websocket upgrade")
|
||||
}
|
||||
key := r.Header.Get("Sec-WebSocket-Key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing key", 400)
|
||||
return nil, fmt.Errorf("missing Sec-WebSocket-Key")
|
||||
}
|
||||
|
||||
h := sha256.Sum256([]byte(key + wsGUID))
|
||||
accept := base64.StdEncoding.EncodeToString(h[:])
|
||||
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijack not supported", 500)
|
||||
return nil, fmt.Errorf("hijack not supported")
|
||||
}
|
||||
|
||||
conn, bufrw, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
http.Error(w, "hijack failed", 500)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := "HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Accept: " + accept + "\r\n\r\n"
|
||||
if _, err := bufrw.WriteString(resp); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := bufrw.Flush(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &WSConn{conn: conn, rw: bufrw}, nil
|
||||
}
|
||||
|
||||
func (ws *WSConn) ReadFrame() (opcode byte, payload []byte, err error) {
|
||||
for {
|
||||
b0, err := ws.rw.ReadByte()
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
opcode = b0 & 0x0F
|
||||
|
||||
b1, err := ws.rw.ReadByte()
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
masked := b1&0x80 != 0
|
||||
length := int64(b1 & 0x7F)
|
||||
|
||||
switch {
|
||||
case length == 126:
|
||||
var b [2]byte
|
||||
if _, err := io.ReadFull(ws.rw, b[:]); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
length = int64(binary.BigEndian.Uint16(b[:]))
|
||||
case length == 127:
|
||||
var b [8]byte
|
||||
if _, err := io.ReadFull(ws.rw, b[:]); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
length = int64(binary.BigEndian.Uint64(b[:]))
|
||||
}
|
||||
|
||||
var maskKey [4]byte
|
||||
if masked {
|
||||
if _, err := io.ReadFull(ws.rw, maskKey[:]); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
payload = make([]byte, length)
|
||||
if _, err := io.ReadFull(ws.rw, payload); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if masked {
|
||||
for i := range payload {
|
||||
payload[i] ^= maskKey[i%4]
|
||||
}
|
||||
}
|
||||
|
||||
if opcode == 0x8 { // Close
|
||||
ws.sendFrame(0x8, nil, false)
|
||||
return opcode, payload, fmt.Errorf("ws closed")
|
||||
}
|
||||
if opcode == 0x9 { // Ping
|
||||
ws.sendFrame(0xA, payload, false) // Pong
|
||||
continue
|
||||
}
|
||||
if opcode == 0xA { // Pong
|
||||
continue
|
||||
}
|
||||
|
||||
return opcode, payload, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WSConn) sendFrame(opcode byte, payload []byte, masked bool) error {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
buf := []byte{0x80 | opcode} // FIN + opcode
|
||||
length := len(payload)
|
||||
|
||||
switch {
|
||||
case length <= 125:
|
||||
if masked {
|
||||
buf = append(buf, byte(length)|0x80)
|
||||
} else {
|
||||
buf = append(buf, byte(length))
|
||||
}
|
||||
case length <= 65535:
|
||||
if masked {
|
||||
buf = append(buf, 126|0x80)
|
||||
} else {
|
||||
buf = append(buf, 126)
|
||||
}
|
||||
b := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(b, uint16(length))
|
||||
buf = append(buf, b...)
|
||||
default:
|
||||
if masked {
|
||||
buf = append(buf, 127|0x80)
|
||||
} else {
|
||||
buf = append(buf, 127)
|
||||
}
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(b, uint64(length))
|
||||
buf = append(buf, b...)
|
||||
}
|
||||
|
||||
var maskKey [4]byte
|
||||
if masked {
|
||||
rand.Read(maskKey[:])
|
||||
buf = append(buf, maskKey[:]...)
|
||||
maskedPayload := make([]byte, length)
|
||||
copy(maskedPayload, payload)
|
||||
for i := range maskedPayload {
|
||||
maskedPayload[i] ^= maskKey[i%4]
|
||||
}
|
||||
buf = append(buf, maskedPayload...)
|
||||
} else {
|
||||
buf = append(buf, payload...)
|
||||
}
|
||||
|
||||
_, err := ws.conn.Write(buf)
|
||||
return err
|
||||
}
|
||||
|
||||
func (ws *WSConn) WriteJSON(v interface{}) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ws.sendFrame(0x1, b, false) // Text frame
|
||||
}
|
||||
|
||||
func (ws *WSConn) ReadJSON(v interface{}) error {
|
||||
_, payload, err := ws.ReadFrame()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(payload, v)
|
||||
}
|
||||
|
||||
func (ws *WSConn) Close() {
|
||||
ws.sendFrame(0x8, nil, false)
|
||||
ws.conn.Close()
|
||||
}
|
||||
|
||||
// ===== Mock Remotedevice 设备桥 =====
|
||||
|
||||
type MockDevice struct {
|
||||
DeviceID string
|
||||
Name string
|
||||
Caps []string
|
||||
Conn *WSConn
|
||||
Online bool
|
||||
}
|
||||
|
||||
type MockRemoteDevice struct {
|
||||
mu sync.Mutex
|
||||
devices map[string]*MockDevice
|
||||
}
|
||||
|
||||
func NewMockRemoteDevice() *MockRemoteDevice {
|
||||
return &MockRemoteDevice{
|
||||
devices: make(map[string]*MockDevice),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MockServer) handleDeviceWS(w http.ResponseWriter, r *http.Request) {
|
||||
ws, err := upgradeWS(w, r)
|
||||
if err != nil {
|
||||
log.Printf("[ws] upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
log.Printf("[ws] 新设备连接")
|
||||
|
||||
// 处理 hello/bind/cmd 协议
|
||||
var device *MockDevice
|
||||
for {
|
||||
var msg struct {
|
||||
Op string `json:"op"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Token string `json:"token"`
|
||||
ReqID string `json:"req_id"`
|
||||
Command string `json:"command"`
|
||||
CmdType string `json:"cmd_type"`
|
||||
Status string `json:"status"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error"`
|
||||
Device json.RawMessage `json:"device"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
if err := ws.ReadJSON(&msg); err != nil {
|
||||
log.Printf("[ws] read error: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
switch msg.Op {
|
||||
case "hello":
|
||||
var devMeta struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Caps []string `json:"caps"`
|
||||
}
|
||||
json.Unmarshal(msg.Device, &devMeta)
|
||||
|
||||
device = &MockDevice{
|
||||
DeviceID: devMeta.DeviceID,
|
||||
Name: devMeta.Name,
|
||||
Caps: devMeta.Caps,
|
||||
Conn: ws,
|
||||
Online: true,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
// 更新设备列表
|
||||
found := false
|
||||
for i := range s.devices {
|
||||
if s.devices[i].DeviceID == devMeta.DeviceID {
|
||||
s.devices[i].Online = true
|
||||
s.devices[i].Caps = devMeta.Caps
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
s.devices = append(s.devices, Device{
|
||||
DeviceID: devMeta.DeviceID,
|
||||
Name: devMeta.Name,
|
||||
Authorized: false,
|
||||
Online: true,
|
||||
Caps: devMeta.Caps,
|
||||
})
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
ws.WriteJSON(map[string]interface{}{
|
||||
"op": "hello_ack",
|
||||
"code": 0,
|
||||
})
|
||||
log.Printf("[ws] 设备登记: %s (%s) caps=%v", devMeta.DeviceID, devMeta.Name, devMeta.Caps)
|
||||
|
||||
case "bind":
|
||||
if msg.DeviceID == "" || msg.Token == "" {
|
||||
ws.WriteJSON(map[string]interface{}{
|
||||
"op": "bind_ack",
|
||||
"code": 1,
|
||||
"error": "missing device_id or token",
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for i := range s.devices {
|
||||
if s.devices[i].DeviceID == msg.DeviceID {
|
||||
s.devices[i].Authorized = true
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
ws.WriteJSON(map[string]interface{}{
|
||||
"op": "bind_ack",
|
||||
"code": 0,
|
||||
})
|
||||
log.Printf("[ws] 设备授权: %s", msg.DeviceID)
|
||||
|
||||
// 绑定成功后,发送全量能力测试命令序列
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 测试 1: screensee 截图
|
||||
log.Printf("[ws] 发命令 1/7: homeagent-screensee")
|
||||
ws.WriteJSON(client.CmdMsg{
|
||||
Op: "cmd",
|
||||
ReqID: "test_1_screensee_" + fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
Command: "homeagent-screensee",
|
||||
CmdType: "homeagent",
|
||||
})
|
||||
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
|
||||
// 测试 2: clipboardsue 写入剪贴板
|
||||
log.Printf("[ws] 发命令 2/7: homeagent-clipboardsue")
|
||||
ws.WriteJSON(client.CmdMsg{
|
||||
Op: "cmd",
|
||||
ReqID: "test_2_clipboardsue_" + fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
Command: "homeagent-clipboardsue HomeAgent远程测试_" + fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
CmdType: "homeagent",
|
||||
})
|
||||
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
|
||||
// 测试 3: clipboardsee 读取剪贴板
|
||||
log.Printf("[ws] 发命令 3/7: homeagent-clipboardsee")
|
||||
ws.WriteJSON(client.CmdMsg{
|
||||
Op: "cmd",
|
||||
ReqID: "test_3_clipboardsee_" + fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
Command: "homeagent-clipboardsee",
|
||||
CmdType: "homeagent",
|
||||
})
|
||||
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
|
||||
// 测试 4: computeruse 鼠标移动(使用非标准 JSON 格式测试兼容性)
|
||||
log.Printf("[ws] 发命令 4/7: homeagent-computeruse move")
|
||||
ws.WriteJSON(client.CmdMsg{
|
||||
Op: "cmd",
|
||||
ReqID: "test_4_computeruse_" + fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
Command: `homeagent-computeruse move {x:500,y:300}`,
|
||||
CmdType: "homeagent",
|
||||
})
|
||||
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
|
||||
// 测试 4b: computeruse 鼠标点击(标准 JSON 格式)
|
||||
log.Printf("[ws] 发命令 4b/7: homeagent-computeruse click")
|
||||
ws.WriteJSON(client.CmdMsg{
|
||||
Op: "cmd",
|
||||
ReqID: "test_4b_click_" + fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
Command: `homeagent-computeruse {"x":800,"y":500,"action":"click","button":"left"}`,
|
||||
CmdType: "homeagent",
|
||||
})
|
||||
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
|
||||
// 测试 5: speakeruse TTS 播报
|
||||
log.Printf("[ws] 发命令 5/7: homeagent-speakeruse")
|
||||
ws.WriteJSON(client.CmdMsg{
|
||||
Op: "cmd",
|
||||
ReqID: "test_5_speakeruse_" + fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
Command: "homeagent-speakeruse 你好,这是来自远程Mock服务器的测试播报",
|
||||
CmdType: "homeagent",
|
||||
})
|
||||
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
|
||||
// 测试 6: screensue 弹窗显示
|
||||
log.Printf("[ws] 发命令 6/7: homeagent-screensue(HTML)")
|
||||
ws.WriteJSON(client.CmdMsg{
|
||||
Op: "cmd",
|
||||
ReqID: "test_6_screensue_" + fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
Command: `homeagent-screensue 10 <!DOCTYPE html><html><head><meta charset="utf-8"><style>body{background:linear-gradient(135deg,#667eea,#764ba2);color:white;font-family:sans-serif;padding:30px;margin:0}h1{font-size:32px;text-shadow:0 2px 8px rgba(0,0,0,0.3)}.card{background:rgba(255,255,255,0.15);border-radius:12px;padding:20px;margin:12px 0;backdrop-filter:blur(8px)}.badge{display:inline-block;background:#4ade80;color:#000;padding:3px 10px;border-radius:16px;font-weight:bold}</style></head><body><h1>HomeAgent GUI 全量测试</h1><div class="card"><h2>能力测试结果</h2><table border="1" cellpadding="6" style="border-collapse:collapse;width:100%"><tr><th>能力</th><th>结果</th></tr><tr><td>screensee 截图</td><td><span class="badge">通过</span></td></tr><tr><td>clipboard 读写</td><td><span class="badge">通过</span></td></tr><tr><td>computeruse 操控</td><td><span class="badge">通过</span></td></tr><tr><td>speakeruse TTS</td><td><span class="badge">通过</span></td></tr><tr><td>screensue 渲染</td><td><span class="badge">通过</span></td></tr></table></div><p style="text-align:center;color:rgba(255,255,255,0.7)">2026-08-23 19:50</p></body></html>`,
|
||||
CmdType: "homeagent",
|
||||
})
|
||||
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
|
||||
// 测试 7: omniparse 解析当前窗口 UI 元素
|
||||
log.Printf("[ws] 发命令 7/7: homeagent-omniparse")
|
||||
ws.WriteJSON(client.CmdMsg{
|
||||
Op: "cmd",
|
||||
ReqID: "test_7_omniparse_" + fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
Command: "homeagent-omniparse",
|
||||
CmdType: "homeagent",
|
||||
})
|
||||
}()
|
||||
|
||||
case "cmd_result":
|
||||
log.Printf("[ws] 命令结果: req=%s status=%s", msg.ReqID, msg.Status)
|
||||
if msg.Output != "" {
|
||||
output := msg.Output
|
||||
if len(output) > 100 {
|
||||
output = output[:100] + "..."
|
||||
}
|
||||
log.Printf("[ws] 输出: %s", output)
|
||||
}
|
||||
if msg.Error != "" {
|
||||
log.Printf("[ws] 错误: %s", msg.Error)
|
||||
}
|
||||
|
||||
case "data_start":
|
||||
var ds client.DataStart
|
||||
json.Unmarshal(msg.Payload, &ds)
|
||||
log.Printf("[ws] 二进制数据开始: req=%s kind=%s mime=%s total=%d", ds.ReqID, ds.Kind, ds.MIME, ds.Total)
|
||||
|
||||
case "data_end":
|
||||
log.Printf("[ws] 二进制数据结束: req=%s status=%s", msg.ReqID, msg.Status)
|
||||
|
||||
case "speech_start":
|
||||
log.Printf("[ws] TTS 音频开始: req=%s", msg.ReqID)
|
||||
|
||||
case "speech_end":
|
||||
log.Printf("[ws] TTS 音频结束: req=%s", msg.ReqID)
|
||||
|
||||
case "status":
|
||||
log.Printf("[ws] 状态上报: %s -> %s", msg.DeviceID, msg.Status)
|
||||
|
||||
case "event":
|
||||
log.Printf("[ws] 事件上报: %s type=%s", msg.DeviceID, string(msg.Payload))
|
||||
|
||||
default:
|
||||
log.Printf("[ws] 未知消息类型: %s", msg.Op)
|
||||
}
|
||||
}
|
||||
|
||||
if device != nil {
|
||||
device.Online = false
|
||||
s.mu.Lock()
|
||||
for i := range s.devices {
|
||||
if s.devices[i].DeviceID == device.DeviceID {
|
||||
s.devices[i].Online = false
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
log.Printf("[ws] 设备断开")
|
||||
}
|
||||
2
cmd/ohos/.gitignore
vendored
Normal file
2
cmd/ohos/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# 开发过程截图(体积大、非源码),不入库
|
||||
screenshots/
|
||||
19
cmd/ohos/HomeAgent/.gitignore
vendored
Normal file
19
cmd/ohos/HomeAgent/.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
# 构建产物
|
||||
build/
|
||||
.hvigor/
|
||||
.cxx/
|
||||
|
||||
# 依赖
|
||||
oh_modules/
|
||||
node_modules/
|
||||
|
||||
# 本地 SDK / Node 路径,每台机器不同
|
||||
local.properties
|
||||
|
||||
# 签名材料:含 keyPassword / storePassword 明文与本机绝对路径,不入库
|
||||
build-profile.json5
|
||||
|
||||
# hvigorw 在本机是指向 /opt/huawei/command-line-tools/bin/hvigorw 的符号链接,
|
||||
# 绝对路径因机而异,入库后他人 clone 得到的是坏链接。
|
||||
# 请改用本机 DevEco command-line-tools 里的 hvigorw(见 README)。
|
||||
hvigorw
|
||||
12
cmd/ohos/HomeAgent/AppScope/app.json5
Normal file
12
cmd/ohos/HomeAgent/AppScope/app.json5
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"app": {
|
||||
"bundleName": "com.example.homeagent",
|
||||
"vendor": "HomeAgent",
|
||||
"versionCode": 1001001,
|
||||
"versionName": "1.1.1",
|
||||
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
|
||||
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "HomeAgent"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/app_icon.png
Normal file
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/app_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/background.png
Normal file
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 989 B |
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/foreground.png
Normal file
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@ -0,0 +1,6 @@
|
||||
{
|
||||
"layered-image": {
|
||||
"background": "$media:background",
|
||||
"foreground": "$media:foreground"
|
||||
}
|
||||
}
|
||||
BIN
cmd/ohos/HomeAgent/AppScope/resources/dark/media/background.png
Normal file
BIN
cmd/ohos/HomeAgent/AppScope/resources/dark/media/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 988 B |
57
cmd/ohos/HomeAgent/build-profile.json5.example
Normal file
57
cmd/ohos/HomeAgent/build-profile.json5.example
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
app: {
|
||||
products: [
|
||||
{
|
||||
name: 'default',
|
||||
signingConfig: 'default',
|
||||
compileSdkVersion: '26.0.0',
|
||||
compatibleSdkVersion: '6.1.1(24)',
|
||||
runtimeOS: 'HarmonyOS',
|
||||
buildOption: {
|
||||
strictMode: {
|
||||
useNormalizedOHMUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
buildModeSet: [
|
||||
{
|
||||
name: 'debug',
|
||||
},
|
||||
{
|
||||
name: 'release',
|
||||
},
|
||||
],
|
||||
// 复制为 build-profile.json5 后,把下面四项换成本机 DevEco 生成的调试签名材料
|
||||
// (默认在 ~/.ohos/config/ 下),keyPassword / storePassword 用自己的值。
|
||||
signingConfigs: [
|
||||
{
|
||||
name: 'default',
|
||||
type: 'HarmonyOS',
|
||||
material: {
|
||||
certpath: 'REPLACE_WITH_YOUR_CER_PATH',
|
||||
keyAlias: 'debugKey',
|
||||
keyPassword: 'REPLACE_WITH_YOUR_KEY_PASSWORD',
|
||||
profile: 'REPLACE_WITH_YOUR_P7B_PATH',
|
||||
signAlg: 'SHA256withECDSA',
|
||||
storeFile: 'REPLACE_WITH_YOUR_P12_PATH',
|
||||
storePassword: 'REPLACE_WITH_YOUR_STORE_PASSWORD',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
modules: [
|
||||
{
|
||||
name: 'entry',
|
||||
srcPath: './entry',
|
||||
targets: [
|
||||
{
|
||||
name: 'default',
|
||||
applyToProducts: [
|
||||
'default',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
1
cmd/ohos/HomeAgent/entry/hvigorfile.ts
Normal file
1
cmd/ohos/HomeAgent/entry/hvigorfile.ts
Normal file
@ -0,0 +1 @@
|
||||
export { hapTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
20
cmd/ohos/HomeAgent/entry/oh-package-lock.json5
Normal file
20
cmd/ohos/HomeAgent/entry/oh-package-lock.json5
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"meta": {
|
||||
"stableOrder": true,
|
||||
"enableUnifiedLockfile": false
|
||||
},
|
||||
"lockfileVersion": 3,
|
||||
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
|
||||
"specifiers": {
|
||||
"@ohos/hypium@1.0.21": "@ohos/hypium@1.0.21"
|
||||
},
|
||||
"packages": {
|
||||
"@ohos/hypium@1.0.21": {
|
||||
"name": "@ohos/hypium",
|
||||
"version": "1.0.21",
|
||||
"integrity": "sha512-iyKGMXxE+9PpCkqEwu0VykN/7hNpb+QOeIuHwkmZnxOpI+dFZt6yhPB7k89EgV1MiSK/ieV/hMjr5Z2mWwRfMQ==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.21.har",
|
||||
"registryType": "ohpm"
|
||||
}
|
||||
}
|
||||
}
|
||||
12
cmd/ohos/HomeAgent/entry/oh-package.json5
Normal file
12
cmd/ohos/HomeAgent/entry/oh-package.json5
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "entry",
|
||||
"version": "1.0.0",
|
||||
"description": "HomeAgent HarmonyOS client entry module",
|
||||
"main": "",
|
||||
"author": "",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@ohos/hypium": "1.0.21"
|
||||
}
|
||||
}
|
||||
221
cmd/ohos/HomeAgent/entry/src/main/ets/common/ApiClient.ets
Normal file
221
cmd/ohos/HomeAgent/entry/src/main/ets/common/ApiClient.ets
Normal file
@ -0,0 +1,221 @@
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
import { DEFAULT_API_TIMEOUT } from './Constants';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
body: string;
|
||||
|
||||
constructor(message: string, status: number, body: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** 二进制响应:附件预览需要原始字节来解码成 PixelMap */
|
||||
export interface ApiBinaryResponse {
|
||||
status: number;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private conn: ConnectionConfig | null = null;
|
||||
|
||||
setConnection(conn: ConnectionConfig): void {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
clearConnection(): void {
|
||||
this.conn = null;
|
||||
}
|
||||
|
||||
getConnection(): ConnectionConfig | null {
|
||||
return this.conn;
|
||||
}
|
||||
|
||||
hasConnection(): boolean {
|
||||
return this.conn !== null && this.conn.url.length > 0;
|
||||
}
|
||||
|
||||
private buildUrl(path: string): string {
|
||||
if (this.conn === null) {
|
||||
return path;
|
||||
}
|
||||
const base = this.conn.url.replace(/\/+$/, '');
|
||||
return base + '/api/v1' + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把附件的 url 字段解析成可直接请求的绝对地址。
|
||||
*
|
||||
* 后端给的是 `/files/<name>` 或 `/uploads/<name>`(注意:不带 /api/v1 前缀),
|
||||
* 远程附件则直接是 http(s) 绝对地址,原样返回。
|
||||
*/
|
||||
absoluteUrl(url: string): string {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
if (this.conn === null) {
|
||||
return url;
|
||||
}
|
||||
const base = this.conn.url.replace(/\/+$/, '');
|
||||
return url.startsWith('/') ? base + url : base + '/' + url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取附件字节。/files/ 与 /uploads/ 走 requireWeb,
|
||||
* 但后端对 API Key 客户端同等放行,所以带上同一套鉴权头即可。
|
||||
*/
|
||||
async getBinary(absUrl: string, timeoutMs: number): Promise<ApiBinaryResponse> {
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.GET,
|
||||
header: this.buildHeaders(),
|
||||
expectDataType: http.HttpDataType.ARRAY_BUFFER,
|
||||
readTimeout: timeoutMs,
|
||||
connectTimeout: Math.min(timeoutMs, 15000),
|
||||
};
|
||||
const resp = await httpRequest.request(absUrl, options);
|
||||
const statusCode = resp.responseCode;
|
||||
if (statusCode >= 400) {
|
||||
throw new ApiError('下载失败', statusCode, '');
|
||||
}
|
||||
const buf: ArrayBuffer = resp.result as ArrayBuffer;
|
||||
return { status: statusCode, data: buf };
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart/form-data 上传。
|
||||
*
|
||||
* 后端 POST /api/v1/chat/file 要求 file 字段为文件本体,
|
||||
* message / device_id / device_name / client_msg_id 为普通文本字段。
|
||||
* ArkTS 侧用 http 的 multiFormDataList:文件走 filePath,文本走 data。
|
||||
*/
|
||||
async postMultipart(path: string, parts: http.MultiFormData[],
|
||||
timeoutMs: number): Promise<ApiResponse> {
|
||||
if (this.conn === null) {
|
||||
throw new ApiError('未选择连接', -1, '');
|
||||
}
|
||||
const url = this.buildUrl(path);
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const header: Record<string, string> = this.buildHeaders();
|
||||
// multipart 的 boundary 由底层生成,必须让出 Content-Type 的控制权
|
||||
header['Content-Type'] = 'multipart/form-data';
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.POST,
|
||||
header: header,
|
||||
expectDataType: http.HttpDataType.STRING,
|
||||
multiFormDataList: parts,
|
||||
readTimeout: timeoutMs,
|
||||
connectTimeout: Math.min(timeoutMs, 15000),
|
||||
};
|
||||
const resp = await httpRequest.request(url, options);
|
||||
const statusCode = resp.responseCode;
|
||||
const body = typeof resp.result === 'string' ? resp.result : '';
|
||||
if (statusCode >= 400) {
|
||||
let errMsg = body;
|
||||
try {
|
||||
const parsed: Record<string, string> = JSON.parse(body) as Record<string, string>;
|
||||
if (parsed['error'] !== undefined) {
|
||||
errMsg = parsed['error'];
|
||||
}
|
||||
} catch (e) {
|
||||
// keep raw body
|
||||
}
|
||||
throw new ApiError(errMsg, statusCode, body);
|
||||
}
|
||||
return { status: statusCode, body: body };
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
headers['Content-Type'] = 'application/json';
|
||||
if (this.conn !== null && this.conn.apiKey.length > 0) {
|
||||
// 后端 validAPIKey 认 X-API-Key,也认 Authorization: Bearer;
|
||||
// query string 形式会被判 401,不要用。
|
||||
headers['X-API-Key'] = this.conn.apiKey;
|
||||
headers['Authorization'] = 'Bearer ' + this.conn.apiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async request(path: string, method: string, bodyStr: string,
|
||||
timeoutMs: number): Promise<ApiResponse> {
|
||||
if (this.conn === null) {
|
||||
throw new ApiError('未选择连接', -1, '');
|
||||
}
|
||||
const url = this.buildUrl(path);
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: (method === 'POST' ? http.RequestMethod.POST :
|
||||
method === 'PUT' ? http.RequestMethod.PUT :
|
||||
method === 'DELETE' ? http.RequestMethod.DELETE :
|
||||
http.RequestMethod.GET) as http.RequestMethod,
|
||||
header: this.buildHeaders(),
|
||||
expectDataType: http.HttpDataType.STRING,
|
||||
readTimeout: timeoutMs,
|
||||
connectTimeout: Math.min(timeoutMs, 15000),
|
||||
};
|
||||
if (bodyStr.length > 0) {
|
||||
options.extraData = bodyStr;
|
||||
}
|
||||
const resp = await httpRequest.request(url, options);
|
||||
const statusCode = resp.responseCode;
|
||||
const body = typeof resp.result === 'string' ? resp.result : '';
|
||||
if (statusCode >= 400) {
|
||||
let errMsg = body;
|
||||
try {
|
||||
const parsed: Record<string, string> = JSON.parse(body) as Record<string, string>;
|
||||
if (parsed['error'] !== undefined) {
|
||||
errMsg = parsed['error'];
|
||||
} else if (parsed['message'] !== undefined) {
|
||||
errMsg = parsed['message'];
|
||||
}
|
||||
} catch (e) {
|
||||
// keep raw body
|
||||
}
|
||||
throw new ApiError(errMsg, statusCode, body);
|
||||
}
|
||||
return { status: statusCode, body: body };
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async get(path: string): Promise<ApiResponse> {
|
||||
return this.request(path, 'GET', '', DEFAULT_API_TIMEOUT);
|
||||
}
|
||||
|
||||
async getWithTimeout(path: string, timeoutMs: number): Promise<ApiResponse> {
|
||||
return this.request(path, 'GET', '', timeoutMs);
|
||||
}
|
||||
|
||||
async post(path: string, bodyObj: object | null): Promise<ApiResponse> {
|
||||
const bodyStr = bodyObj === null ? '' : JSON.stringify(bodyObj);
|
||||
return this.request(path, 'POST', bodyStr, DEFAULT_API_TIMEOUT);
|
||||
}
|
||||
|
||||
async postWithTimeout(path: string, bodyObj: object | null,
|
||||
timeoutMs: number): Promise<ApiResponse> {
|
||||
const bodyStr = bodyObj === null ? '' : JSON.stringify(bodyObj);
|
||||
return this.request(path, 'POST', bodyStr, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient: ApiClient = new ApiClient();
|
||||
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 附件的字节获取与解码(网络 / 沙箱 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;
|
||||
}
|
||||
}
|
||||
103
cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentMeta.ets
Normal file
103
cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentMeta.ets
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 附件的解析与格式化(纯函数,无 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;
|
||||
}
|
||||
297
cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets
Normal file
297
cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets
Normal file
@ -0,0 +1,297 @@
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { util } from '@kit.ArkTS';
|
||||
import { pasteboard } from '@kit.BasicServicesKit';
|
||||
import { deviceInfo } from '@kit.BasicServicesKit';
|
||||
import { textToSpeech } from '@kit.CoreSpeechKit';
|
||||
import { componentSnapshot } from '@kit.ArkUI';
|
||||
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
|
||||
|
||||
// ===== 能力结果 =====
|
||||
|
||||
/** 与 BridgeRouter 实际支持的本机命令保持一一对应。 */
|
||||
export const LOCAL_DEVICE_CAPS: string[] = [
|
||||
'status',
|
||||
'deviceinfo',
|
||||
'screensee',
|
||||
'screensue',
|
||||
'clipboardsee',
|
||||
'clipboardsue',
|
||||
'speakeruse',
|
||||
];
|
||||
|
||||
export interface CapResult {
|
||||
status: string; // 'ok' | 'error'
|
||||
output: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface DeviceStatusPayload {
|
||||
device_id: string;
|
||||
status: string;
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
interface DeviceDetails {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
os_release: string;
|
||||
version: string;
|
||||
cpus: number;
|
||||
brand: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
series: string;
|
||||
sdk_api_version: number;
|
||||
security_patch: string;
|
||||
abi_list: string;
|
||||
device_type: string;
|
||||
}
|
||||
|
||||
interface DeviceInfoPayload {
|
||||
device_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
caps: string[];
|
||||
info: DeviceDetails;
|
||||
}
|
||||
|
||||
const APP_STARTED_AT: number = Date.now();
|
||||
|
||||
function okResult(output: string): CapResult {
|
||||
const r: CapResult = { status: 'ok', output: output, error: '' };
|
||||
return r;
|
||||
}
|
||||
|
||||
function errResult(errMsg: string): CapResult {
|
||||
const r: CapResult = { status: 'error', output: '', error: errMsg };
|
||||
return r;
|
||||
}
|
||||
|
||||
// ===== screensee:截取本应用当前画面(前台时为整屏可见内容)=====
|
||||
|
||||
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
|
||||
|
||||
/** 根组件 id(Index 的根 Stack 设置同名 .id()),截屏时按此定位。 */
|
||||
export function snapshotComponentId(): string {
|
||||
return SNAPSHOT_COMPONENT_ID;
|
||||
}
|
||||
|
||||
async function captureScreenPixelMap(): Promise<image.PixelMap> {
|
||||
const pm: image.PixelMap = await componentSnapshot.get(SNAPSHOT_COMPONENT_ID);
|
||||
return pm;
|
||||
}
|
||||
|
||||
/**
|
||||
* screensee 实现:截本应用画面,缩放到最大宽度 720px 后压成 jpeg base64 data URL。
|
||||
* 说明:鸿蒙三方应用无法无弹窗截取整个系统屏幕(CUSTOM_SCREEN_CAPTURE 为系统权限),
|
||||
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
|
||||
*/
|
||||
export async function capScreensee(): Promise<CapResult> {
|
||||
let full: image.PixelMap | null = null;
|
||||
let packer: image.ImagePacker | null = null;
|
||||
try {
|
||||
full = await captureScreenPixelMap();
|
||||
const info: image.ImageInfo = await full.getImageInfo();
|
||||
const maxW: number = 420;
|
||||
const maxH: number = 640;
|
||||
let scale: number = 1;
|
||||
if (info.size.width > maxW) {
|
||||
scale = maxW / info.size.width;
|
||||
}
|
||||
if (info.size.height * scale > maxH) {
|
||||
scale = maxH / info.size.height;
|
||||
}
|
||||
if (scale < 1) {
|
||||
await full.scale(scale, scale);
|
||||
}
|
||||
packer = image.createImagePacker();
|
||||
const opt: image.PackingOption = { format: 'image/jpeg', quality: 55 };
|
||||
const packed: ArrayBuffer = await packer.packing(full, opt);
|
||||
const helper: util.Base64Helper = new util.Base64Helper();
|
||||
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
|
||||
if (b64.length > 950000) {
|
||||
return errResult('当前画面数据过大,请稍后重试');
|
||||
}
|
||||
return okResult('data:image/jpeg;base64,' + b64);
|
||||
} catch (e) {
|
||||
return errResult('无法读取当前应用画面,请保持应用在前台后重试');
|
||||
} finally {
|
||||
if (packer !== null) {
|
||||
packer.release();
|
||||
}
|
||||
if (full !== null) {
|
||||
full.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== clipboardsee / clipboardsue =====
|
||||
|
||||
const CLIPBOARD_PERMISSIONS: Array<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
|
||||
|
||||
/**
|
||||
* READ_PASTEBOARD 是 user_grant 权限:仅在 agent 真正请求 clipboardsee 时弹出系统授权,
|
||||
* 不在应用启动时抢先索权。已授权时系统会直接返回,不会重复打扰用户。
|
||||
*/
|
||||
async function ensureClipboardPermission(context: common.UIAbilityContext): Promise<boolean> {
|
||||
try {
|
||||
const atManager = abilityAccessCtrl.createAtManager();
|
||||
const result: PermissionRequestResult =
|
||||
await atManager.requestPermissionsFromUser(context, CLIPBOARD_PERMISSIONS);
|
||||
return result.authResults.length > 0 && result.authResults[0] === 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
|
||||
const granted: boolean = await ensureClipboardPermission(context);
|
||||
if (!granted) {
|
||||
return errResult('剪贴板读取权限未授予,请在系统设置中允许后重试');
|
||||
}
|
||||
try {
|
||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||
const has: boolean = await clip.hasData();
|
||||
if (!has) {
|
||||
return okResult('');
|
||||
}
|
||||
const data: pasteboard.PasteData = await clip.getData();
|
||||
const txt: string = data.getPrimaryText();
|
||||
return okResult(txt ?? '');
|
||||
} catch (e) {
|
||||
return errResult('剪贴板读取失败,请确认应用在前台并已获得系统授权');
|
||||
}
|
||||
}
|
||||
|
||||
export async function capClipboardsue(text: string): Promise<CapResult> {
|
||||
try {
|
||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
|
||||
await clip.setPasteData(data);
|
||||
return okResult('clipboard written');
|
||||
} catch (e) {
|
||||
return errResult('剪贴板写入失败,请保持应用在前台后重试');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== speakeruse:TTS 朗读 =====
|
||||
|
||||
class TtsSession {
|
||||
private engine: textToSpeech.TextToSpeechEngine | null = null;
|
||||
|
||||
async speak(text: string): Promise<CapResult> {
|
||||
if (text.length > 4000) {
|
||||
return errResult('朗读内容过长,请缩短到 4000 字以内');
|
||||
}
|
||||
try {
|
||||
if (this.engine === null) {
|
||||
const params: textToSpeech.CreateEngineParams = {
|
||||
language: 'zh-CN',
|
||||
person: 0,
|
||||
online: 1,
|
||||
};
|
||||
const eng: textToSpeech.TextToSpeechEngine = await textToSpeech.createEngine(params);
|
||||
this.engine = eng;
|
||||
}
|
||||
const sp: textToSpeech.SpeakParams = {
|
||||
requestId: 'spk-' + Date.now().toString(),
|
||||
};
|
||||
this.engine.speak(text, sp);
|
||||
return okResult('speaking');
|
||||
} catch (e) {
|
||||
return errResult('语音服务暂时不可用,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.engine !== null) {
|
||||
try {
|
||||
this.engine.shutdown();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
this.engine = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ttsSession: TtsSession = new TtsSession();
|
||||
|
||||
export async function capSpeakerUse(text: string): Promise<CapResult> {
|
||||
return ttsSession.speak(text);
|
||||
}
|
||||
|
||||
export function shutdownSpeakerUse(): void {
|
||||
ttsSession.shutdown();
|
||||
}
|
||||
|
||||
// ===== status / deviceinfo =====
|
||||
|
||||
export function capStatus(deviceId: string): CapResult {
|
||||
const payload: DeviceStatusPayload = {
|
||||
device_id: deviceId,
|
||||
status: 'online',
|
||||
hostname: 'ohos-phone',
|
||||
platform: 'OpenHarmony',
|
||||
arch: deviceInfo.abiList,
|
||||
uptime: Math.floor((Date.now() - APP_STARTED_AT) / 1000),
|
||||
};
|
||||
return okResult(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
|
||||
const details: DeviceDetails = {
|
||||
hostname: 'ohos-phone',
|
||||
platform: 'OpenHarmony',
|
||||
arch: deviceInfo.abiList,
|
||||
os_release: deviceInfo.osFullName,
|
||||
version: '1.1.1',
|
||||
cpus: 0,
|
||||
brand: deviceInfo.brand,
|
||||
manufacturer: deviceInfo.manufacture,
|
||||
model: deviceInfo.productModel,
|
||||
series: deviceInfo.productSeries,
|
||||
sdk_api_version: deviceInfo.sdkApiVersion,
|
||||
security_patch: deviceInfo.securityPatchTag,
|
||||
abi_list: deviceInfo.abiList,
|
||||
device_type: deviceInfo.deviceType,
|
||||
};
|
||||
const payload: DeviceInfoPayload = {
|
||||
device_id: deviceId,
|
||||
name: deviceName,
|
||||
kind: 'ohos-phone',
|
||||
caps: LOCAL_DEVICE_CAPS,
|
||||
info: details,
|
||||
};
|
||||
return okResult(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// ===== 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;
|
||||
}
|
||||
172
cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeProtocol.ets
Normal file
172
cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeProtocol.ets
Normal file
@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 设备桥协议:消息结构、帧构造与分块切片。
|
||||
*
|
||||
* 与 homed 的 remotedevice 插件对齐(internal/plugins/remotedevice)。
|
||||
* 从 common/DeviceBridge.ets 抽出:这里只有"协议形状"和"帧怎么拼",
|
||||
* 没有任何 socket、状态机与重连逻辑 —— 那些留在 DeviceBridgeClient 里。
|
||||
*
|
||||
* 注意:DeviceBridgeClient 的对外方法名与语义不因本文件而改变,
|
||||
* 各 send* 方法仍是"拼帧 + 发出去"两步,只是第一步搬到了这里。
|
||||
*/
|
||||
|
||||
import { CapResult } from './BridgeCaps';
|
||||
|
||||
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
||||
|
||||
export interface HelloDeviceInfo {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
os_release: string;
|
||||
version: string;
|
||||
cpus: number;
|
||||
}
|
||||
|
||||
export interface HelloDevice {
|
||||
device_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
authorized: boolean;
|
||||
caps: string[];
|
||||
info: HelloDeviceInfo;
|
||||
}
|
||||
|
||||
export interface HelloMessage {
|
||||
op: string;
|
||||
device: HelloDevice;
|
||||
}
|
||||
|
||||
export interface BindMessage {
|
||||
op: string;
|
||||
device_id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface CmdReply {
|
||||
op: string; // 'cmd_result'
|
||||
req_id: string;
|
||||
status: string;
|
||||
output: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface DataStartMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
kind: string;
|
||||
mime: string;
|
||||
total: number;
|
||||
chunk_size: number;
|
||||
}
|
||||
|
||||
export interface DataEndMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
status: string;
|
||||
total?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const CHUNK_SIZE: number = 8192;
|
||||
|
||||
// ===== 命令处理器回调 =====
|
||||
// 返回 CapResult;二进制大结果通过 dataHandler 分块回传。
|
||||
export type BridgeCmdHandler = (reqId: string, command: string) => Promise<CapResult>;
|
||||
|
||||
// ===== 帧构造 =====
|
||||
|
||||
export function bridgeHelloFrame(deviceId: string, name: string, kind: string,
|
||||
caps: string[], hostname: string,
|
||||
authorized: boolean): string {
|
||||
const info: HelloDeviceInfo = {
|
||||
hostname: hostname,
|
||||
platform: 'OpenHarmony',
|
||||
arch: '',
|
||||
os_release: '',
|
||||
version: '1.1.1',
|
||||
cpus: 0,
|
||||
};
|
||||
const device: HelloDevice = {
|
||||
device_id: deviceId,
|
||||
name: name,
|
||||
kind: kind,
|
||||
authorized: authorized,
|
||||
caps: caps,
|
||||
info: info,
|
||||
};
|
||||
const hello: HelloMessage = { op: 'hello', device: device };
|
||||
return JSON.stringify(hello);
|
||||
}
|
||||
|
||||
export function bridgeBindFrame(deviceId: string, token: string): string {
|
||||
const bind: BindMessage = {
|
||||
op: 'bind',
|
||||
device_id: deviceId,
|
||||
token: token,
|
||||
};
|
||||
return JSON.stringify(bind);
|
||||
}
|
||||
|
||||
export function bridgeResultFrame(reqId: string, status: string,
|
||||
output: string, errMsg: string): string {
|
||||
const result: CmdReply = {
|
||||
op: 'cmd_result',
|
||||
req_id: reqId,
|
||||
status: status,
|
||||
output: output,
|
||||
error: errMsg,
|
||||
};
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
export function bridgeDataStartFrame(reqId: string, kind: string, mime: string,
|
||||
total: number): string {
|
||||
const startMsg: DataStartMessage = {
|
||||
op: 'cmd_data_start',
|
||||
req_id: reqId,
|
||||
kind: kind,
|
||||
mime: mime,
|
||||
total: total,
|
||||
chunk_size: CHUNK_SIZE,
|
||||
};
|
||||
return JSON.stringify(startMsg);
|
||||
}
|
||||
|
||||
export function bridgeDataEndFrame(reqId: string): string {
|
||||
const endMsg: DataEndMessage = {
|
||||
op: 'cmd_data_end',
|
||||
req_id: reqId,
|
||||
status: 'ok',
|
||||
};
|
||||
return JSON.stringify(endMsg);
|
||||
}
|
||||
|
||||
export function bridgeEventFrame(deviceId: string, eventType: string, detail: string): string {
|
||||
const payload: Record<string, string> = { 'detail': detail };
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'event',
|
||||
'device_id': deviceId,
|
||||
'type': eventType,
|
||||
'payload': payload,
|
||||
};
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
export function bridgeStatusFrame(deviceId: string, status: string): string {
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'status',
|
||||
'device_id': deviceId,
|
||||
'status': status,
|
||||
};
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
/** 按 CHUNK_SIZE 切二进制;切片顺序即发送顺序。 */
|
||||
export function bridgeChunkSlices(bytes: Uint8Array): Uint8Array[] {
|
||||
const out: Uint8Array[] = [];
|
||||
for (let off: number = 0; off < bytes.byteLength; off += CHUNK_SIZE) {
|
||||
const end: number = Math.min(off + CHUNK_SIZE, bytes.byteLength);
|
||||
out.push(bytes.slice(off, end));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
136
cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets
Normal file
136
cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets
Normal file
@ -0,0 +1,136 @@
|
||||
import { deviceBridge } from './DeviceBridge';
|
||||
import {
|
||||
CapResult,
|
||||
capScreensee,
|
||||
capClipboardSee,
|
||||
capClipboardsue,
|
||||
capSpeakerUse,
|
||||
capDeviceInfo,
|
||||
capStatus,
|
||||
parseScreensue,
|
||||
ScreensuePayload,
|
||||
} from './BridgeCaps';
|
||||
import { connStore } from './ConnStore';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
// screensue 展示回调由根 UI 注册:窄屏整页,宽屏右侧内容栏。
|
||||
export type ScreensueHandler = (payload: ScreensuePayload) => void;
|
||||
|
||||
let screensueHandler: ScreensueHandler | null = null;
|
||||
let appContext: common.UIAbilityContext | null = null;
|
||||
|
||||
export function registerScreensueHandler(handler: ScreensueHandler): void {
|
||||
screensueHandler = handler;
|
||||
}
|
||||
|
||||
export function setBridgeAppContext(ctx: common.UIAbilityContext): void {
|
||||
appContext = ctx;
|
||||
}
|
||||
|
||||
/** 解析裸能力名或过渡期 homeagent-* 命令;参数正文不裁剪,避免改变推送内容。 */
|
||||
function splitCapability(command: string): string[] {
|
||||
let start: number = 0;
|
||||
while (start < command.length && isCommandSpace(command.charAt(start))) {
|
||||
start = start + 1;
|
||||
}
|
||||
let cmd: string = command.substring(start);
|
||||
if (cmd.startsWith('homeagent-')) {
|
||||
cmd = cmd.substring('homeagent-'.length);
|
||||
}
|
||||
let idx: number = -1;
|
||||
for (let i: number = 0; i < cmd.length; i++) {
|
||||
if (isCommandSpace(cmd.charAt(i))) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (idx < 0) {
|
||||
return [cmd];
|
||||
}
|
||||
const out: string[] = [cmd.substring(0, idx), cmd.substring(idx + 1)];
|
||||
return out;
|
||||
}
|
||||
|
||||
function isCommandSpace(ch: string): boolean {
|
||||
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
|
||||
}
|
||||
|
||||
function hasArgs(args: string): boolean {
|
||||
return args.trim().length > 0;
|
||||
}
|
||||
|
||||
async function executeCommand(reqId: string, command: string): Promise<CapResult> {
|
||||
const parts: string[] = splitCapability(command);
|
||||
const name: string = parts[0];
|
||||
const args: string = parts.length > 1 ? parts[1] : '';
|
||||
|
||||
if (name === 'screensee') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('screensee 不接受额外参数');
|
||||
}
|
||||
return capScreensee();
|
||||
}
|
||||
if (name === 'screensue') {
|
||||
if (!hasArgs(args)) {
|
||||
return errRes('screensue 需要展示内容');
|
||||
}
|
||||
const payload: ScreensuePayload = parseScreensue(args);
|
||||
if (payload.content.length === 0) {
|
||||
return errRes('screensue 需要展示内容');
|
||||
}
|
||||
if (screensueHandler !== null) {
|
||||
screensueHandler(payload);
|
||||
return okRes('内容已显示');
|
||||
}
|
||||
return errRes('展示界面尚未就绪,请保持应用在前台后重试');
|
||||
}
|
||||
if (name === 'clipboardsee') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('clipboardsee 不接受额外参数');
|
||||
}
|
||||
if (appContext === null) {
|
||||
return errRes('应用界面尚未就绪,请保持应用在前台后重试');
|
||||
}
|
||||
return capClipboardSee(appContext);
|
||||
}
|
||||
if (name === 'clipboardsue') {
|
||||
if (!hasArgs(args)) {
|
||||
return errRes('clipboardsue 需要写入文字');
|
||||
}
|
||||
return capClipboardsue(args);
|
||||
}
|
||||
if (name === 'speakeruse') {
|
||||
if (!hasArgs(args)) {
|
||||
return errRes('speakeruse 需要朗读文字');
|
||||
}
|
||||
return capSpeakerUse(args);
|
||||
}
|
||||
if (name === 'status') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('status 不接受额外参数');
|
||||
}
|
||||
return capStatus(connStore.getDeviceId());
|
||||
}
|
||||
if (name === 'deviceinfo') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('deviceinfo 不接受额外参数');
|
||||
}
|
||||
return capDeviceInfo(connStore.getDeviceId(), connStore.getDeviceName());
|
||||
}
|
||||
return errRes('不支持的本机能力:' + name);
|
||||
}
|
||||
|
||||
function okRes(output: string): CapResult {
|
||||
const r: CapResult = { status: 'ok', output: output, error: '' };
|
||||
return r;
|
||||
}
|
||||
|
||||
function errRes(errMsg: string): CapResult {
|
||||
const r: CapResult = { status: 'error', output: '', error: errMsg };
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 安装命令处理器到 bridge 单例。 */
|
||||
export function installCmdRouter(): void {
|
||||
deviceBridge.setCmdHandler(executeCommand);
|
||||
}
|
||||
223
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatFormat.ets
Normal file
223
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatFormat.ets
Normal file
@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 聊天页的纯格式化/判定逻辑(无 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%)';
|
||||
}
|
||||
96
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets
Normal file
96
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* /chat/history 响应解析(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出:首屏与向上翻页共用同一套解析口径,
|
||||
* 消息 id 由调用方提供的分配器给出(页面自己维护 id 计数器)。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
||||
import { parseAttachment } from './AttachmentMeta';
|
||||
import { stringifyField } from './ChatFormat';
|
||||
|
||||
/** 分页历史解析结果:消息列表 + 服务端分页元数据 */
|
||||
export interface ParsedHistory {
|
||||
msgs: ChatMessage[];
|
||||
/** 本页首条在服务端全量历史中的下标,作为下次向上翻页的 before 游标 */
|
||||
offset: number;
|
||||
/** 服务端是否还有更早的历史 */
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/** 解析后端 /chat/history 的响应体(含分页元数据),供首屏与翻页复用。 */
|
||||
export function parseHistoryPayload(
|
||||
obj: Record<string, Object>, alloc: () => number): ParsedHistory {
|
||||
const rawList: Object | undefined = obj['messages'] as Object | undefined;
|
||||
if (rawList === undefined || rawList === null) {
|
||||
return { msgs: [], offset: 0, hasMore: false };
|
||||
}
|
||||
const arr: Object[] = rawList as Object[];
|
||||
const msgs: ChatMessage[] = [];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const role: string = item['role'] as string ?? '';
|
||||
const content: string = item['content'] as string ?? '';
|
||||
const att: ChatAttachment | undefined = parseAttachment(item['attachment']);
|
||||
// 纯附件消息 content 可能为空,不能再按"无内容就丢弃"处理
|
||||
if (role.length === 0 || (content.length === 0 && att === undefined)) {
|
||||
continue;
|
||||
}
|
||||
const msg: ChatMessage = {
|
||||
id: alloc(),
|
||||
role: role,
|
||||
content: content,
|
||||
isFinal: true,
|
||||
};
|
||||
if (att !== undefined) {
|
||||
msg.attachment = att;
|
||||
}
|
||||
// 后端 handler.go 保证 history 不裁剪 reasoning_content / tool_calls,
|
||||
// 这里必须还原,否则刷新后思考与工具卡就凭空消失。
|
||||
const rc: string = item['reasoning_content'] as string ?? '';
|
||||
if (rc.length > 0) {
|
||||
msg.reasoningContent = rc;
|
||||
}
|
||||
const tcs: ToolCallInfo[] | undefined = parseHistoryToolCalls(item['tool_calls']);
|
||||
if (tcs !== undefined) {
|
||||
msg.toolCalls = tcs;
|
||||
}
|
||||
// 渠道/设备来源:后端 ChatMsg.source,用于区分 channel_output 等非 webui 消息
|
||||
const src: string = item['source'] as string ?? '';
|
||||
if (src.length > 0) {
|
||||
msg.source = src;
|
||||
}
|
||||
msgs.push(msg);
|
||||
}
|
||||
const offset: number = typeof obj['offset'] === 'number' ? obj['offset'] as number : 0;
|
||||
const hasMore: boolean = obj['has_more'] === true;
|
||||
return { msgs: msgs, offset: offset, hasMore: hasMore };
|
||||
}
|
||||
|
||||
/** 后端 tool_calls 条目带 tool 和 name 两份;args/result 可能是对象也可能是字符串。 */
|
||||
export function parseHistoryToolCalls(raw: Object | undefined): ToolCallInfo[] | undefined {
|
||||
if (raw === undefined || raw === null) {
|
||||
return undefined;
|
||||
}
|
||||
const arr: Object[] = raw as Object[];
|
||||
if (arr.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const tcs: ToolCallInfo[] = [];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const name: string = (item['tool'] as string ?? '') || (item['name'] as string ?? '');
|
||||
if (name.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const tc: ToolCallInfo = {
|
||||
name: name,
|
||||
args: stringifyField(item['args']),
|
||||
result: stringifyField(item['result']),
|
||||
status: item['status'] as string ?? undefined,
|
||||
plugin: item['plugin'] as string ?? undefined,
|
||||
};
|
||||
tcs.push(tc);
|
||||
}
|
||||
return tcs.length > 0 ? tcs : undefined;
|
||||
}
|
||||
176
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets
Normal file
176
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets
Normal file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 发送 / 中断(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出:POST /chat 与 POST /chat/file 的请求体、
|
||||
* 兜底消息合并、超时口径都收在这里,输入区组件只负责把文本/附件递进来。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ChatAttachment } from '../model/Model';
|
||||
import { apiClient } from './ApiClient';
|
||||
import { connStore } from './ConnStore';
|
||||
import { userMessage, isTimeout } from './UserError';
|
||||
import { chatStore } from './ChatStore';
|
||||
import { parseAttachment } from './AttachmentMeta';
|
||||
import { http } from '@kit.NetworkKit';
|
||||
|
||||
interface SendChatBody {
|
||||
message: string;
|
||||
client_msg_id: string;
|
||||
/** 非空时后端编码 source = "webui/<device_id>",agent 可见来源设备 */
|
||||
device_id?: string;
|
||||
device_name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯文本发送:POST /chat。
|
||||
* 带附件的情况走 sendChatFile(后端收下附件后自己写会话并触发 agent)。
|
||||
*/
|
||||
export async function sendChatText(text: string): Promise<void> {
|
||||
const trimmed: string = text.trim();
|
||||
if (chatStore.isLoading()) {
|
||||
return;
|
||||
}
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur === null) {
|
||||
return;
|
||||
}
|
||||
if (trimmed.length === 0) {
|
||||
return;
|
||||
}
|
||||
// 重置 SSE 标记
|
||||
chatStore.setSseActive(false);
|
||||
|
||||
const userMsg: ChatMessage = { id: chatStore.allocId(), role: 'user', content: trimmed };
|
||||
chatStore.pushNew(userMsg);
|
||||
chatStore.setLoading(true);
|
||||
chatStore.setStage('等待 AI 回复...');
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
|
||||
const bodyObj: SendChatBody = {
|
||||
message: trimmed,
|
||||
client_msg_id: Date.now().toString(36),
|
||||
// 必须带设备身份:后端没有 device_id 就把来源编码成 webui,
|
||||
// agent 会以为消息来自网页端。device_id 非空时后端编码
|
||||
// source = "webui/<device_id>" 并注入设备上下文。
|
||||
device_id: connStore.ensureDeviceId(),
|
||||
device_name: connStore.getDeviceName(),
|
||||
};
|
||||
|
||||
// 如果 SSE 已连接,POST 作为触发器(响应由 SSE 推送渲染);
|
||||
// 仅在 SSE 未推送内容时才用 POST 响应兜底创建消息。
|
||||
try {
|
||||
const resp = await apiClient.postWithTimeout('/chat', bodyObj, 120000);
|
||||
|
||||
// SSE 已经处理了响应,跳过 POST 消息创建
|
||||
if (chatStore.sseActive()) {
|
||||
chatStore.setLoading(false);
|
||||
chatStore.setStage('');
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed: Record<string, string> = JSON.parse(resp.body) as Record<string, string>;
|
||||
const respText: string = parsed['response'] ?? '(无响应)';
|
||||
const reasoning: string = parsed['reasoning_content'] ?? '';
|
||||
const last: ChatMessage | null = chatStore.lastMessage();
|
||||
if (last !== null && last.role === 'assistant' && !last.isFinal) {
|
||||
last.content = respText;
|
||||
last.isFinal = true;
|
||||
last.isStreaming = false;
|
||||
if (reasoning.length > 0 && last.reasoningContent === undefined) {
|
||||
last.reasoningContent = reasoning;
|
||||
}
|
||||
} else if (last !== null && last.role === 'assistant' && last.isFinal) {
|
||||
// 已有最终消息,合并(不应发生,但防御性处理)
|
||||
if (respText.length > last.content.length) {
|
||||
last.content = respText;
|
||||
}
|
||||
} else {
|
||||
const msg: ChatMessage = {
|
||||
id: chatStore.allocId(),
|
||||
role: 'assistant',
|
||||
content: respText,
|
||||
isFinal: true,
|
||||
};
|
||||
if (reasoning.length > 0) {
|
||||
msg.reasoningContent = reasoning;
|
||||
}
|
||||
chatStore.pushNew(msg);
|
||||
}
|
||||
chatStore.setLoading(false);
|
||||
chatStore.setStage('');
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
} catch (e) {
|
||||
// 超时通常意味着后端仍在生成,不算失败;其余一律显示人话,
|
||||
// 原始错误只进 hilog(之前把 e.message 拼进 chatStage 会把
|
||||
// "Failed to connect to the server."、内网地址直接摆到聊天流里)。
|
||||
if (isTimeout(e)) {
|
||||
chatStore.setStage('请求已发送,等待回复...');
|
||||
} else {
|
||||
chatStore.setStage(userMessage('chat.send', e));
|
||||
}
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带附件发送:POST /chat/file(multipart),字段与 WebGUI 一致。
|
||||
* 后端收下后自身会把用户消息与附件写进会话并触发 agent,
|
||||
* 回复照常从 SSE 过来,所以这里不再走 /chat。
|
||||
*/
|
||||
export async function sendChatFile(text: string, path: string, name: string,
|
||||
size: number, isImage: boolean, mime: string): Promise<void> {
|
||||
if (connStore.getCurrentConnection() === null) {
|
||||
return;
|
||||
}
|
||||
const att: ChatAttachment = {
|
||||
type: isImage ? 'image' : 'file',
|
||||
// 本地待上传:先用沙箱路径预览,上传成功后替换成服务端 URL
|
||||
url: 'file://' + path,
|
||||
size: size,
|
||||
name: name,
|
||||
};
|
||||
const userMsg: ChatMessage = { id: chatStore.allocId(), role: 'user', content: text };
|
||||
userMsg.attachment = att;
|
||||
chatStore.pushNew(userMsg);
|
||||
chatStore.setLoading(true);
|
||||
chatStore.setStage('正在上传附件...');
|
||||
chatStore.setSseActive(false);
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
|
||||
const parts: http.MultiFormData[] = [
|
||||
{ name: 'file', contentType: mime, remoteFileName: name, filePath: path },
|
||||
{ name: 'message', contentType: 'text/plain', data: text },
|
||||
{ name: 'client_msg_id', contentType: 'text/plain', data: Date.now().toString(36) },
|
||||
{ name: 'device_id', contentType: 'text/plain', data: connStore.ensureDeviceId() },
|
||||
{ name: 'device_name', contentType: 'text/plain', data: connStore.getDeviceName() },
|
||||
];
|
||||
|
||||
try {
|
||||
const resp = await apiClient.postMultipart('/chat/file', parts, 180000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const uploaded: ChatAttachment | undefined = parseAttachment(obj['file']);
|
||||
if (uploaded !== undefined) {
|
||||
userMsg.attachment = uploaded;
|
||||
}
|
||||
chatStore.setStage('等待 AI 回复...');
|
||||
} catch (e) {
|
||||
chatStore.setStage(userMessage('chat.upload', e));
|
||||
chatStore.setLoading(false);
|
||||
}
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
}
|
||||
|
||||
export async function interruptChat(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/chat/interrupt', null);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
196
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets
Normal file
196
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets
Normal file
@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
389
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatStore.ets
Normal file
389
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatStore.ets
Normal file
@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 聊天流状态源(单例)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出:消息数组、分页游标、"哪几条是新消息"、
|
||||
* SSE 连接与历史拉取都属于同一个状态机;页面只剩渲染与输入。
|
||||
*
|
||||
* 为什么数组不进 AppStorage:StatusStore 已经踩过一次 —— 数组同步语义不可靠。
|
||||
* 这里沿用同一套做法:数组留在 store 内部,标量走 AppStorage 广播,
|
||||
* 另加一个自增版本号 K_CHAT_REV 通知订阅组件"重取一次快照"。
|
||||
*
|
||||
* 订阅组件的接法(见 components/ChatStream.ets):
|
||||
* @StorageProp(K_CHAT_REV) @Watch('onRev') private rev: number = 0;
|
||||
* onRev(): void { this.messages = chatStore.messages(); }
|
||||
* ForEach 拿到的仍然是"每次刷新一个新数组引用",与拆分前
|
||||
* (this.messages = this.messages.slice())的渲染语义完全一致。
|
||||
*
|
||||
* SSE 事件的翻译在 common/ChatSse.ets:本类实现它的 ChatStreamSink 接口,
|
||||
* 依赖方向只有"ChatStore → ChatSse"一条,不构成循环。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
||||
import { SseClient, SseEvent } from './SseClient';
|
||||
import { connStore } from './ConnStore';
|
||||
import { apiClient } from './ApiClient';
|
||||
import { CHAT_PAGE_SIZE } from './Constants';
|
||||
import { ParsedHistory, parseHistoryPayload } from './ChatHistory';
|
||||
import { applyChatSse, ChatStreamSink } from './ChatSse';
|
||||
|
||||
// ===== AppStorage 键:页面/聊天流/输入区共用 =====
|
||||
export const K_CHAT_REV: string = 'chatRev';
|
||||
export const K_CHAT_SCROLL_REV: string = 'chatScrollRev';
|
||||
export const K_CHAT_LOADING: string = 'chatBusy';
|
||||
export const K_CHAT_STAGE: string = 'chatStageText';
|
||||
export const K_CHAT_CONNECTED: string = 'chatSseUp';
|
||||
|
||||
const SSE_RECONNECT_MS: number = 5000;
|
||||
|
||||
class ChatStore implements ChatStreamSink {
|
||||
private msgs: ChatMessage[] = [];
|
||||
private nextId: number = 1;
|
||||
private refreshTimer: number = -1;
|
||||
private reconnectTimer: number = -1;
|
||||
/** 分页历史:当前已加载消息在服务端全量中的起始下标 */
|
||||
private offset: number = 0;
|
||||
/** 是否还有更早历史可向上加载 */
|
||||
private hasEarlier: boolean = false;
|
||||
private loadingOlder: boolean = false;
|
||||
/** 正在为新消息播入场动画的 id */
|
||||
private newIds: number[] = [];
|
||||
// SSE 正在为当前轮次推送内容时置 true,阻止 POST 响应重复创建消息
|
||||
private sseActiveForTurn: boolean = false;
|
||||
private sse: SseClient = new SseClient();
|
||||
|
||||
init(): void {
|
||||
AppStorage.setOrCreate<boolean>(K_CHAT_LOADING, false);
|
||||
AppStorage.setOrCreate<string>(K_CHAT_STAGE, '');
|
||||
AppStorage.setOrCreate<boolean>(K_CHAT_CONNECTED, false);
|
||||
AppStorage.setOrCreate<number>(K_CHAT_REV, 0);
|
||||
AppStorage.setOrCreate<number>(K_CHAT_SCROLL_REV, 0);
|
||||
}
|
||||
|
||||
// ===================== 读取 =====================
|
||||
|
||||
messages(): ChatMessage[] {
|
||||
return this.msgs;
|
||||
}
|
||||
|
||||
findMessage(id: number): ChatMessage | undefined {
|
||||
for (let i = 0; i < this.msgs.length; i++) {
|
||||
if (this.msgs[i].id === id) {
|
||||
return this.msgs[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
lastMessage(): ChatMessage | null {
|
||||
if (this.msgs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.msgs[this.msgs.length - 1];
|
||||
}
|
||||
|
||||
isFresh(id: number): boolean {
|
||||
return this.newIds.indexOf(id) >= 0;
|
||||
}
|
||||
|
||||
hasMore(): boolean {
|
||||
return this.hasEarlier;
|
||||
}
|
||||
|
||||
isFetchingOlder(): boolean {
|
||||
return this.loadingOlder;
|
||||
}
|
||||
|
||||
isLoading(): boolean {
|
||||
return AppStorage.get<boolean>(K_CHAT_LOADING) ?? false;
|
||||
}
|
||||
|
||||
setLoading(v: boolean): void {
|
||||
AppStorage.set<boolean>(K_CHAT_LOADING, v);
|
||||
}
|
||||
|
||||
setStage(s: string): void {
|
||||
AppStorage.set<string>(K_CHAT_STAGE, s);
|
||||
}
|
||||
|
||||
setConnected(v: boolean): void {
|
||||
AppStorage.set<boolean>(K_CHAT_CONNECTED, v);
|
||||
}
|
||||
|
||||
sseActive(): boolean {
|
||||
return this.sseActiveForTurn;
|
||||
}
|
||||
|
||||
setSseActive(v: boolean): void {
|
||||
this.sseActiveForTurn = v;
|
||||
}
|
||||
|
||||
/** 聊天流里最后一个带附件的消息:宽屏进入 Split 时用它填充右栏 */
|
||||
latestAttachment(): ChatAttachment | undefined {
|
||||
for (let i = this.msgs.length - 1; i >= 0; i--) {
|
||||
const a: ChatAttachment | undefined = this.msgs[i].attachment;
|
||||
if (a !== undefined) {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ===================== 列表变更 =====================
|
||||
|
||||
allocId(): number {
|
||||
return this.nextId++;
|
||||
}
|
||||
|
||||
/** 追加一条消息并播入场动画 */
|
||||
pushNew(msg: ChatMessage): void {
|
||||
this.msgs.push(msg);
|
||||
this.markNew(msg.id);
|
||||
}
|
||||
|
||||
/** 标记新消息,触发入场动画 */
|
||||
markNew(msgId: number): void {
|
||||
const arr: number[] = this.newIds.slice();
|
||||
arr.push(msgId);
|
||||
this.newIds = arr;
|
||||
// 这里不切片也不广播:与拆分前一致,入场动画的开场交给紧随其后的
|
||||
// refresh()(50ms 防抖后换新数组引用)那一次一起触发。
|
||||
setTimeout(() => {
|
||||
const idx: number = this.newIds.indexOf(msgId);
|
||||
if (idx >= 0) {
|
||||
const updated: number[] = this.newIds.slice();
|
||||
updated.splice(idx, 1);
|
||||
this.newIds = updated;
|
||||
this.forceRefresh();
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前助手消息里名为 name 的未完成工具卡,没有就建一张。
|
||||
* 顺带保证一定存在一条"未定稿的助手消息"来挂这些卡。
|
||||
*/
|
||||
ensureToolCall(name: string): ToolCallInfo {
|
||||
const last: ChatMessage = this.ensureAssistant();
|
||||
if (last.toolCalls === undefined) {
|
||||
last.toolCalls = [];
|
||||
}
|
||||
for (let i = 0; i < last.toolCalls.length; i++) {
|
||||
const t: ToolCallInfo = last.toolCalls[i];
|
||||
// 只复用"仍在执行"的同名卡:同一轮里同名工具被多次调用时,
|
||||
// 已完成的那张不能被后来的调用覆盖。
|
||||
const st: string | undefined = t.status;
|
||||
if (t.name === name && (st === undefined || st.length === 0 || st === 'running')) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
const created: ToolCallInfo = { name: name, args: '' };
|
||||
last.toolCalls.push(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保证存在一条"未定稿的助手消息",返回它。
|
||||
* 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条),
|
||||
* 此时还没有"未完成的助手消息",必须新建一条,否则内容直接丢失。
|
||||
*/
|
||||
ensureAssistant(): ChatMessage {
|
||||
const last: ChatMessage | null = this.lastMessage();
|
||||
if (last !== null && last.role === 'assistant' && last.isFinal !== true) {
|
||||
return last;
|
||||
}
|
||||
const msg: ChatMessage = {
|
||||
id: this.allocId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
isStreaming: true,
|
||||
isFinal: false,
|
||||
};
|
||||
this.pushNew(msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** 流式/高频变更的通知信号:让 ChatStream 滚到底 */
|
||||
requestScroll(): void {
|
||||
const cur: number = AppStorage.get<number>(K_CHAT_SCROLL_REV) ?? 0;
|
||||
AppStorage.set<number>(K_CHAT_SCROLL_REV, cur + 1);
|
||||
}
|
||||
|
||||
/** 防抖刷新:合并高频 SSE delta,最多 ~20fps */
|
||||
refresh(): void {
|
||||
if (this.refreshTimer >= 0) {
|
||||
return;
|
||||
}
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refreshTimer = -1;
|
||||
this.msgs = this.msgs.slice();
|
||||
this.bump();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/** 强制立即刷新(用于状态切换等需要即时响应的场景) */
|
||||
forceRefresh(): void {
|
||||
if (this.refreshTimer >= 0) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = -1;
|
||||
}
|
||||
this.msgs = this.msgs.slice();
|
||||
this.bump();
|
||||
}
|
||||
|
||||
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
|
||||
bump(): void {
|
||||
const cur: number = AppStorage.get<number>(K_CHAT_REV) ?? 0;
|
||||
AppStorage.set<number>(K_CHAT_REV, cur + 1);
|
||||
}
|
||||
|
||||
cancelRefresh(): void {
|
||||
if (this.refreshTimer >= 0) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 折叠开关 =====================
|
||||
|
||||
reasoningOpen(msgId: number): boolean {
|
||||
const m: ChatMessage | undefined = this.findMessage(msgId);
|
||||
return m !== undefined && m.reasoningOpen === true;
|
||||
}
|
||||
|
||||
setReasoningOpen(msgId: number, open: boolean): void {
|
||||
const m: ChatMessage | undefined = this.findMessage(msgId);
|
||||
if (m !== undefined) {
|
||||
m.reasoningOpen = open;
|
||||
}
|
||||
}
|
||||
|
||||
toolOpen(msgId: number, index: number): boolean {
|
||||
const m: ChatMessage | undefined = this.findMessage(msgId);
|
||||
if (m === undefined || m.toolCalls === undefined || index >= m.toolCalls.length) {
|
||||
return false;
|
||||
}
|
||||
return m.toolCalls[index].open === true;
|
||||
}
|
||||
|
||||
setToolOpen(msgId: number, index: number, open: boolean): void {
|
||||
const m: ChatMessage | undefined = this.findMessage(msgId);
|
||||
if (m !== undefined && m.toolCalls !== undefined && index < m.toolCalls.length) {
|
||||
m.toolCalls[index].open = open;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 历史 =====================
|
||||
|
||||
private parseHistory(obj: Record<string, Object>): ParsedHistory {
|
||||
return parseHistoryPayload(obj, () => this.allocId());
|
||||
}
|
||||
|
||||
async loadHistory(): Promise<void> {
|
||||
try {
|
||||
// 分段懒加载:首屏只拉最新 CHAT_PAGE_SIZE 条,向上滚动触顶再拉更早的。
|
||||
const resp = await apiClient.getWithTimeout('/chat/history?limit=' + CHAT_PAGE_SIZE, 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const parsed: ParsedHistory = this.parseHistory(obj);
|
||||
if (parsed.msgs.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.msgs = parsed.msgs;
|
||||
this.offset = parsed.offset;
|
||||
this.hasEarlier = parsed.hasMore;
|
||||
this.forceRefresh();
|
||||
this.requestScroll();
|
||||
} catch (e) {
|
||||
// ignore history load failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向上翻页:拉 offset 之前的更早一页,前置到 messages 头部并保持滚动位置。
|
||||
* 触顶(yOffset 接近 0)且有更早历史时由 onDidScroll 触发。
|
||||
*/
|
||||
async loadOlder(): Promise<void> {
|
||||
if (this.loadingOlder || !this.hasEarlier) {
|
||||
return;
|
||||
}
|
||||
this.loadingOlder = true;
|
||||
try {
|
||||
const before: number = this.offset;
|
||||
if (before <= 0) {
|
||||
this.hasEarlier = false;
|
||||
return;
|
||||
}
|
||||
const resp = await apiClient.getWithTimeout(
|
||||
'/chat/history?limit=' + CHAT_PAGE_SIZE + '&before=' + before, 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const parsed: ParsedHistory = this.parseHistory(obj);
|
||||
if (parsed.msgs.length === 0) {
|
||||
this.hasEarlier = false;
|
||||
return;
|
||||
}
|
||||
// 前置插入新页(更早的在前),追加到当前列表头部;id 用新分配的避免与新消息撞号
|
||||
this.msgs = parsed.msgs.concat(this.msgs);
|
||||
this.offset = parsed.offset;
|
||||
this.hasEarlier = parsed.hasMore;
|
||||
this.bump();
|
||||
} catch (e) {
|
||||
// 失败保留 hasEarlier,允许下次滚动重试
|
||||
} finally {
|
||||
this.loadingOlder = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** sync_required:断线重连后服务端要求补拉历史 */
|
||||
reloadHistory(): void {
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
// ===================== SSE 连接 =====================
|
||||
|
||||
connect(): void {
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur === null) {
|
||||
return;
|
||||
}
|
||||
this.sse.close();
|
||||
this.sse.connect(cur, '/chat/events',
|
||||
(ev: SseEvent) => {
|
||||
applyChatSse(ev, this);
|
||||
},
|
||||
() => {
|
||||
this.setConnected(false);
|
||||
this.scheduleReconnect();
|
||||
},
|
||||
() => {
|
||||
this.setConnected(true);
|
||||
}).catch(() => {
|
||||
this.setConnected(false);
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
scheduleReconnect(): void {
|
||||
if (this.reconnectTimer >= 0) {
|
||||
return;
|
||||
}
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = -1;
|
||||
this.connect();
|
||||
}, SSE_RECONNECT_MS);
|
||||
}
|
||||
|
||||
cancelReconnect(): void {
|
||||
if (this.reconnectTimer >= 0) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面消失:断线、停表,避免后台空转 */
|
||||
disconnect(): void {
|
||||
this.cancelReconnect();
|
||||
this.sse.close();
|
||||
this.cancelRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
export const chatStore: ChatStore = new ChatStore();
|
||||
236
cmd/ohos/HomeAgent/entry/src/main/ets/common/ConnStore.ets
Normal file
236
cmd/ohos/HomeAgent/entry/src/main/ets/common/ConnStore.ets
Normal file
@ -0,0 +1,236 @@
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { Context } from '@kit.AbilityKit';
|
||||
import { deviceInfo } from '@kit.BasicServicesKit';
|
||||
import { ConnectionConfig, AppSettings, emptySettings, defaultConnection, normalizeBaseUrl } from '../model/Model';
|
||||
|
||||
const PREF_NAME: string = 'homeagent_prefs';
|
||||
const KEY_CONNECTIONS: string = 'connections_json';
|
||||
const KEY_SETTINGS: string = 'settings_json';
|
||||
const KEY_DEVICE_ID: string = 'device_id';
|
||||
const KEY_DEVICE_NAME: string = 'device_name';
|
||||
const KEY_DEVICE_AUTH: string = 'device_authorized';
|
||||
|
||||
export class ConnStore {
|
||||
private prefs: preferences.Preferences | null = null;
|
||||
private connections: ConnectionConfig[] = [];
|
||||
private settings: AppSettings = emptySettings();
|
||||
|
||||
async init(context: Context): Promise<void> {
|
||||
this.prefs = await preferences.getPreferences(context, PREF_NAME);
|
||||
await this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
if (this.prefs === null) {
|
||||
return;
|
||||
}
|
||||
const connJson: string = await this.prefs.get(KEY_CONNECTIONS, '') as string;
|
||||
if (connJson.length > 0) {
|
||||
try {
|
||||
this.connections = JSON.parse(connJson) as ConnectionConfig[];
|
||||
} catch (e) {
|
||||
this.connections = [];
|
||||
}
|
||||
// 存量数据可能是旧版本存进去的裸域名或带尾斜杠的地址,读出来时一并规范化,
|
||||
// 否则老用户升级后仍然会拼出错误的请求地址。
|
||||
for (let i = 0; i < this.connections.length; i++) {
|
||||
const c: ConnectionConfig = this.connections[i];
|
||||
c.url = normalizeBaseUrl(c.url);
|
||||
}
|
||||
}
|
||||
const settingsJson: string = await this.prefs.get(KEY_SETTINGS, '') as string;
|
||||
if (settingsJson.length > 0) {
|
||||
try {
|
||||
this.settings = JSON.parse(settingsJson) as AppSettings;
|
||||
} catch (e) {
|
||||
this.settings = emptySettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不预置任何连接:地址与密钥属于用户私有配置,不能随源码分发。
|
||||
// 连接列表为空时各页面走"尚未配置后端连接"的统一提示,用户到设置页添加。
|
||||
|
||||
getConnections(): ConnectionConfig[] {
|
||||
return this.connections;
|
||||
}
|
||||
|
||||
getCurrentConnection(): ConnectionConfig | null {
|
||||
if (this.settings.currentConnId.length === 0) {
|
||||
if (this.connections.length > 0) {
|
||||
return this.connections[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < this.connections.length; i++) {
|
||||
const c: ConnectionConfig = this.connections[i];
|
||||
if (c.id === this.settings.currentConnId) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
if (this.connections.length > 0) {
|
||||
return this.connections[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async addConnection(name: string, url: string, apiKey: string): Promise<ConnectionConfig> {
|
||||
const conn: ConnectionConfig = defaultConnection();
|
||||
conn.id = Date.now().toString(36) + Math.floor(Math.random() * 10000).toString(36);
|
||||
conn.name = name;
|
||||
conn.url = normalizeBaseUrl(url);
|
||||
conn.apiKey = apiKey;
|
||||
conn.type = 'webui';
|
||||
this.connections.push(conn);
|
||||
if (this.settings.currentConnId.length === 0) {
|
||||
this.settings.currentConnId = conn.id;
|
||||
}
|
||||
await this.save();
|
||||
return conn;
|
||||
}
|
||||
|
||||
async updateConnection(id: string, name: string, url: string,
|
||||
apiKey: string): Promise<void> {
|
||||
for (let i = 0; i < this.connections.length; i++) {
|
||||
const c: ConnectionConfig = this.connections[i];
|
||||
if (c.id === id) {
|
||||
c.name = name;
|
||||
c.url = normalizeBaseUrl(url);
|
||||
c.apiKey = apiKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async deleteConnection(id: string): Promise<void> {
|
||||
const next: ConnectionConfig[] = [];
|
||||
for (let i = 0; i < this.connections.length; i++) {
|
||||
const c: ConnectionConfig = this.connections[i];
|
||||
if (c.id !== id) {
|
||||
next.push(c);
|
||||
}
|
||||
}
|
||||
this.connections = next;
|
||||
if (this.settings.currentConnId === id) {
|
||||
if (this.connections.length > 0) {
|
||||
this.settings.currentConnId = this.connections[0].id;
|
||||
} else {
|
||||
this.settings.currentConnId = '';
|
||||
}
|
||||
}
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async setCurrent(id: string): Promise<void> {
|
||||
this.settings.currentConnId = id;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getSettings(): AppSettings {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
async saveSettings(s: AppSettings): Promise<void> {
|
||||
this.settings = s;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/** Persisted local device id (stable across restarts). */
|
||||
getDeviceId(): string {
|
||||
if (this.prefs === null) {
|
||||
return '';
|
||||
}
|
||||
return this.prefs.getSync(KEY_DEVICE_ID, '') as string;
|
||||
}
|
||||
|
||||
saveDeviceId(id: string): void {
|
||||
if (this.prefs === null) {
|
||||
return;
|
||||
}
|
||||
this.prefs.putSync(KEY_DEVICE_ID, id);
|
||||
this.prefs.flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取本机设备 ID,没有就地生成并持久化。
|
||||
*
|
||||
* 聊天发送必须带 device_id:后端 handleChat 只有拿到它才会把来源编码成
|
||||
* webui/<device_id> 并往 stageCtx 注入"当前输入来自设备[...]",
|
||||
* 否则 source 恒为 "webui",agent 会以为消息是网页端发的。
|
||||
* 之前只有设备页在用时才生成 ID,聊天页拿到空串 → 身份丢失。
|
||||
*/
|
||||
ensureDeviceId(): string {
|
||||
const cur: string = this.getDeviceId();
|
||||
if (cur.length > 0) {
|
||||
return cur;
|
||||
}
|
||||
const id: string = 'ohos-' + Date.now().toString(36);
|
||||
this.saveDeviceId(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 设备显示名:优先用户自定义,否则按机型自动生成("HUAWEI Mate 60 (HarmonyOS)")。 */
|
||||
getDeviceName(): string {
|
||||
if (this.prefs !== null) {
|
||||
const saved: string = this.prefs.getSync(KEY_DEVICE_NAME, '') as string;
|
||||
if (saved.length > 0) {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
return ConnStore.autoDeviceName();
|
||||
}
|
||||
|
||||
saveDeviceName(name: string): void {
|
||||
if (this.prefs === null) {
|
||||
return;
|
||||
}
|
||||
this.prefs.putSync(KEY_DEVICE_NAME, name);
|
||||
this.prefs.flush();
|
||||
}
|
||||
|
||||
/** 由 deviceInfo 拼一个人看得懂的机器名;静态方法内不能用 this。 */
|
||||
private static autoDeviceName(): string {
|
||||
let name: string = '';
|
||||
try {
|
||||
const brand: string = deviceInfo.brand;
|
||||
const model: string = deviceInfo.productModel;
|
||||
if (model.length > 0) {
|
||||
name = brand.length > 0 && model.indexOf(brand) < 0 ? brand + ' ' + model : model;
|
||||
}
|
||||
} catch (e) {
|
||||
name = '';
|
||||
}
|
||||
if (name.length === 0) {
|
||||
name = 'HarmonyOS 设备';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Local authorization flag reported via hello; server stores nothing. */
|
||||
getDeviceAuth(): boolean {
|
||||
if (this.prefs === null) {
|
||||
return false;
|
||||
}
|
||||
return this.prefs.getSync(KEY_DEVICE_AUTH, false) as boolean;
|
||||
}
|
||||
|
||||
saveDeviceAuth(on: boolean): void {
|
||||
if (this.prefs === null) {
|
||||
return;
|
||||
}
|
||||
this.prefs.putSync(KEY_DEVICE_AUTH, on);
|
||||
this.prefs.flush();
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
if (this.prefs === null) {
|
||||
return;
|
||||
}
|
||||
await this.prefs.put(KEY_CONNECTIONS, JSON.stringify(this.connections));
|
||||
await this.prefs.put(KEY_SETTINGS, JSON.stringify(this.settings));
|
||||
await this.prefs.flush();
|
||||
}
|
||||
}
|
||||
|
||||
export const connStore: ConnStore = new ConnStore();
|
||||
347
cmd/ohos/HomeAgent/entry/src/main/ets/common/Constants.ets
Normal file
347
cmd/ohos/HomeAgent/entry/src/main/ets/common/Constants.ets
Normal file
@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Design tokens — mirror of gui/renderer/style.css `:root` (dark theme).
|
||||
* All values copied 1:1 from the web GUI to keep visual identity identical.
|
||||
*
|
||||
* Theme system:
|
||||
* - ThemePalette holds every color token used by the app.
|
||||
* - DARK_PALETTE mirrors style.css `:root`; LIGHT_PALETTE mirrors
|
||||
* `[data-theme="light"][data-color="sakura"]`.
|
||||
* - themeStore keeps a global AppStorage("themeIsDark") flag that every page
|
||||
* reads through @StorageProp, so the whole UI re-renders on switch.
|
||||
*/
|
||||
|
||||
// ===== network =====
|
||||
export const DEFAULT_API_TIMEOUT: number = 120000;
|
||||
export const SSE_RECONNECT_DELAY: number = 5000;
|
||||
export const DEFAULT_WS_PORT: number = 9890;
|
||||
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
|
||||
export const CHAT_PAGE_SIZE: number = 40;
|
||||
|
||||
// ===== sakura / frost palette (style.css :root) =====
|
||||
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_300: string = '#5B93F8';
|
||||
export const COLOR_SAKURA_400: string = '#0A59F7';
|
||||
export const COLOR_SAKURA_500: string = '#0A59F7';
|
||||
export const COLOR_SAKURA_600: string = '#0A59F7';
|
||||
export const COLOR_SAKURA_700: string = '#0846C2';
|
||||
export const COLOR_FROST_300: string = '#4A90D9';
|
||||
export const COLOR_FROST_400: string = '#3A78B5';
|
||||
export const COLOR_FROST_500: string = '#2C5E8C';
|
||||
export const COLOR_SUCCESS: string = '#30B260';
|
||||
export const COLOR_WARNING: string = '#D99A2B';
|
||||
export const COLOR_ERROR: string = '#E84026';
|
||||
export const COLOR_INFO: string = '#3F6EF5';
|
||||
export const COLOR_CYAN: string = '#2DD4BF';
|
||||
export const COLOR_VIOLET: string = '#A78BFA';
|
||||
export const COLOR_EMERALD: string = '#34D399';
|
||||
export const COLOR_AMBER: string = '#FBBF24';
|
||||
export const COLOR_BLUE: string = '#60A5FA';
|
||||
|
||||
// ===== 宽屏(平板 / 折叠展开 / 分屏)适配 =====
|
||||
/**
|
||||
* 宽屏断点:窗口宽度 >= 600vp 视为宽屏。
|
||||
* 600vp = Navigation 分栏所需的 minNavBarWidth(240) + minContentWidth(360),
|
||||
* 与系统 NavigationMode.Auto 的切换阈值保持一致,避免自判与系统行为脱节。
|
||||
*/
|
||||
export const WIDE_MIN_WIDTH: number = 600;
|
||||
/** 宽屏下左侧一级界面栏宽度(vp):左边一级界面,右边二级界面 */
|
||||
export const WIDE_NAV_BAR_WIDTH: number = 420;
|
||||
/** 宽屏下右侧二级界面的最小宽度(vp),不足时左栏被压缩 */
|
||||
export const WIDE_MIN_CONTENT: number = 380;
|
||||
/** 悬浮导航胶囊的最大宽度(vp):宽屏下避免被拉成长条 */
|
||||
export const NAV_PILL_MAX_WIDTH: number = 620;
|
||||
|
||||
// ===== 动效时长(ms)=====
|
||||
/**
|
||||
* 统一动效节奏,避免每处各写一个魔数导致快慢不一。
|
||||
*
|
||||
* 取值依据:状态切换类交互(按下、展开、选中)控制在 150-250ms,
|
||||
* 超过 300ms 会显得"拖";进出场大位移(导航栏收起、悬浮层)用 300-400ms
|
||||
* 才不突兀。曲线统一 EaseOut:起步快、收尾缓,手感上更"跟手"。
|
||||
*/
|
||||
/** 按压反馈、图标旋转等即时反馈 */
|
||||
export const ANIM_FAST: number = 150;
|
||||
/** 展开/折叠、选中态迁移等常规状态切换 */
|
||||
export const ANIM_NORMAL: number = 220;
|
||||
/** 卡片入场、列表项出现 */
|
||||
export const ANIM_ENTER: number = 280;
|
||||
/** 导航栏收起、悬浮层进出等大位移 */
|
||||
export const ANIM_SLOW: number = 400;
|
||||
/** 按压态缩放:轻微下沉即可,过度缩放显廉价 */
|
||||
export const PRESS_SCALE: number = 0.97;
|
||||
|
||||
/** Every color token the app renders with. */
|
||||
export interface ThemePalette {
|
||||
bgPrimary: string;
|
||||
bgSecondary: string;
|
||||
bgCard: string;
|
||||
bgInput: string;
|
||||
bgHover: string;
|
||||
glassBgStrong: string;
|
||||
glassBorder: string;
|
||||
border: string;
|
||||
kvBorder: string;
|
||||
btnGhostBorder: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
textMuted: string;
|
||||
textTertiary: string;
|
||||
accent: string;
|
||||
accentBg: string;
|
||||
preBg: string;
|
||||
preText: string;
|
||||
msgUserBg: string;
|
||||
msgUserText: string;
|
||||
msgAssistantBg: string;
|
||||
msgAssistantText: string;
|
||||
msgBubbleBg: string;
|
||||
msgBubbleText: string;
|
||||
msgBubbleBorder: string;
|
||||
msgUserBubbleBg: string;
|
||||
msgUserBubbleBorder: string;
|
||||
msgAssistantBubbleBg: string;
|
||||
msgAssistantBubbleBorder: string;
|
||||
toastBg: string;
|
||||
toastText: string;
|
||||
toastErrorBg: string;
|
||||
toastErrorText: string;
|
||||
toastWarnBg: string;
|
||||
toastWarnText: string;
|
||||
successSoftBg: string;
|
||||
errorSoftBg: string;
|
||||
frostSoftBg: string;
|
||||
navBarBg: string;
|
||||
navBarBorder: string;
|
||||
navBarGradientStart: string;
|
||||
navBarGradientEnd: string;
|
||||
shadow: string;
|
||||
gradA: string; // sakura top-right glow (start color)
|
||||
gradB: string; // frost mid-left glow
|
||||
gradC: string; // sakura bottom glow
|
||||
gradAEnd: string; // transparent end color for radial fade
|
||||
gradBEnd: string;
|
||||
gradCEnd: string;
|
||||
}
|
||||
|
||||
/** Dark palette — HarmonyOS system-app style neutral dark. */
|
||||
export const DARK_PALETTE: ThemePalette = {
|
||||
bgPrimary: '#000000',
|
||||
bgSecondary: 'rgba(28, 28, 30, 0.72)',
|
||||
bgCard: 'rgba(28, 28, 30, 0.6)',
|
||||
bgInput: 'rgba(44, 44, 46, 0.92)',
|
||||
bgHover: 'rgba(255, 255, 255, 0.08)',
|
||||
glassBgStrong: 'rgba(28, 28, 30, 0.82)',
|
||||
glassBorder: 'rgba(255, 255, 255, 0.1)',
|
||||
border: 'rgba(255, 255, 255, 0.1)',
|
||||
kvBorder: 'rgba(255, 255, 255, 0.08)',
|
||||
btnGhostBorder: 'rgba(255, 255, 255, 0.18)',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#D1D1D6',
|
||||
textMuted: '#98989F',
|
||||
textTertiary: '#8E8E93',
|
||||
accent: '#0A59F7',
|
||||
accentBg: 'rgba(10, 89, 247, 0.18)',
|
||||
preBg: 'rgba(20, 20, 22, 0.9)',
|
||||
preText: '#D6E4FF',
|
||||
msgUserBg: 'rgba(10, 89, 247, 0.2)',
|
||||
msgUserText: '#9DC0FC',
|
||||
msgAssistantBg: 'rgba(120, 120, 128, 0.24)',
|
||||
msgAssistantText: '#E5E5EA',
|
||||
msgBubbleBg: '#1C1C1E',
|
||||
msgBubbleText: '#F2F2F7',
|
||||
msgBubbleBorder: 'rgba(255, 255, 255, 0.12)',
|
||||
msgUserBubbleBg: 'rgba(10, 89, 247, 0.18)',
|
||||
msgUserBubbleBorder: 'rgba(10, 89, 247, 0.25)',
|
||||
msgAssistantBubbleBg: 'rgba(58, 58, 60, 0.72)',
|
||||
msgAssistantBubbleBorder: 'rgba(255, 255, 255, 0.1)',
|
||||
toastBg: 'rgba(48, 178, 96, 0.18)',
|
||||
toastText: '#4CD47A',
|
||||
toastErrorBg: 'rgba(232, 64, 38, 0.2)',
|
||||
toastErrorText: '#FF6B4A',
|
||||
toastWarnBg: 'rgba(255, 159, 10, 0.16)',
|
||||
toastWarnText: '#FFCC66',
|
||||
successSoftBg: 'rgba(48, 178, 96, 0.16)',
|
||||
errorSoftBg: 'rgba(232, 64, 38, 0.18)',
|
||||
frostSoftBg: 'rgba(74, 144, 217, 0.14)',
|
||||
navBarBg: 'rgba(24, 24, 26, 0.45)',
|
||||
navBarBorder: 'rgba(255, 255, 255, 0.14)',
|
||||
navBarGradientStart: 'rgba(40, 40, 44, 0.35)',
|
||||
navBarGradientEnd: 'rgba(20, 20, 22, 0.5)',
|
||||
shadow: 'rgba(0, 0, 0, 0.5)',
|
||||
gradA: 'rgba(10, 89, 247, 0.1)',
|
||||
gradB: 'rgba(74, 144, 217, 0.07)',
|
||||
gradC: 'rgba(94, 92, 230, 0.06)',
|
||||
gradAEnd: 'rgba(10, 89, 247, 0)',
|
||||
gradBEnd: 'rgba(74, 144, 217, 0)',
|
||||
gradCEnd: 'rgba(94, 92, 230, 0)',
|
||||
};
|
||||
|
||||
/** Light palette — HarmonyOS system-app style neutral light. */
|
||||
export const LIGHT_PALETTE: ThemePalette = {
|
||||
bgPrimary: '#F1F3F5',
|
||||
bgSecondary: 'rgba(255, 255, 255, 0.85)',
|
||||
bgCard: 'rgba(255, 255, 255, 0.95)',
|
||||
bgInput: 'rgba(118, 118, 128, 0.28)',
|
||||
bgHover: 'rgba(0, 0, 0, 0.05)',
|
||||
glassBgStrong: 'rgba(255, 255, 255, 0.92)',
|
||||
glassBorder: 'rgba(60, 60, 67, 0.12)',
|
||||
border: 'rgba(60, 60, 67, 0.12)',
|
||||
kvBorder: 'rgba(60, 60, 67, 0.1)',
|
||||
btnGhostBorder: 'rgba(60, 60, 67, 0.2)',
|
||||
textPrimary: '#191919',
|
||||
textSecondary: '#494949',
|
||||
textMuted: '#777779',
|
||||
textTertiary: '#8A8A8E',
|
||||
accent: '#0A59F7',
|
||||
accentBg: 'rgba(10, 89, 247, 0.1)',
|
||||
preBg: 'rgba(118, 118, 128, 0.1)',
|
||||
preText: '#3C3C43',
|
||||
msgUserBg: 'rgba(10, 89, 247, 0.12)',
|
||||
msgUserText: '#0A59F7',
|
||||
msgAssistantBg: '#FFFFFF',
|
||||
msgAssistantText: '#333333',
|
||||
msgBubbleBg: '#FFFFFF',
|
||||
msgBubbleText: '#191919',
|
||||
msgBubbleBorder: 'rgba(60, 60, 67, 0.12)',
|
||||
msgUserBubbleBg: 'rgba(10, 89, 247, 0.1)',
|
||||
msgUserBubbleBorder: 'rgba(10, 89, 247, 0.18)',
|
||||
msgAssistantBubbleBg: 'rgba(255, 255, 255, 0.88)',
|
||||
msgAssistantBubbleBorder: 'rgba(60, 60, 67, 0.1)',
|
||||
toastBg: 'rgba(48, 178, 96, 0.14)',
|
||||
toastText: '#157347',
|
||||
toastErrorBg: 'rgba(232, 64, 38, 0.12)',
|
||||
toastErrorText: '#C0361F',
|
||||
toastWarnBg: 'rgba(255, 159, 10, 0.14)',
|
||||
toastWarnText: '#8F5A00',
|
||||
successSoftBg: 'rgba(48, 178, 96, 0.12)',
|
||||
errorSoftBg: 'rgba(232, 64, 38, 0.1)',
|
||||
frostSoftBg: 'rgba(74, 144, 217, 0.12)',
|
||||
navBarBg: 'rgba(250, 250, 252, 0.55)',
|
||||
navBarBorder: 'rgba(60, 60, 67, 0.15)',
|
||||
navBarGradientStart: 'rgba(255, 255, 255, 0.35)',
|
||||
navBarGradientEnd: 'rgba(240, 240, 245, 0.5)',
|
||||
shadow: 'rgba(0, 0, 0, 0.1)',
|
||||
gradA: 'rgba(10, 89, 247, 0.06)',
|
||||
gradB: 'rgba(74, 144, 217, 0.05)',
|
||||
gradC: 'rgba(94, 92, 230, 0.04)',
|
||||
gradAEnd: 'rgba(10, 89, 247, 0)',
|
||||
gradBEnd: 'rgba(74, 144, 217, 0)',
|
||||
gradCEnd: 'rgba(94, 92, 230, 0)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy single-theme constants kept for incremental migration.
|
||||
* New code should use tp() / ThemePalette instead.
|
||||
*/
|
||||
export const COLOR_BG_PRIMARY: string = '#000000';
|
||||
export const COLOR_BG_SECONDARY: string = 'rgba(17, 24, 44, 0.72)';
|
||||
export const COLOR_BG_CARD: string = 'rgba(17, 24, 44, 0.6)';
|
||||
export const COLOR_BG_INPUT: string = 'rgba(13, 18, 34, 0.75)';
|
||||
export const COLOR_BG_HOVER: string = 'rgba(255, 255, 255, 0.06)';
|
||||
export const COLOR_GLASS_BG: string = 'rgba(13, 18, 34, 0.6)';
|
||||
export const COLOR_GLASS_BG_STRONG: string = 'rgba(13, 18, 32, 0.82)';
|
||||
export const COLOR_GLASS_BORDER: string = 'rgba(255, 255, 255, 0.08)';
|
||||
export const COLOR_GLASS_HOVER: string = 'rgba(255, 255, 255, 0.05)';
|
||||
|
||||
export const COLOR_TEXT_PRIMARY: string = '#FFFFFF';
|
||||
export const COLOR_TEXT_SECONDARY: string = 'rgba(255, 255, 255, 0.6)';
|
||||
export const COLOR_TEXT_MUTED: string = 'rgba(255, 255, 255, 0.4)';
|
||||
export const COLOR_TEXT_TERTIARY: string = 'rgba(255, 255, 255, 0.45)';
|
||||
|
||||
export const COLOR_ACCENT: string = '#0A59F7';
|
||||
export const COLOR_ACCENT_BG: string = 'rgba(10, 89, 247, 0.14)';
|
||||
export const COLOR_BORDER: string = 'rgba(255, 255, 255, 0.09)';
|
||||
export const COLOR_KV_BORDER: string = 'rgba(255, 255, 255, 0.07)';
|
||||
export const COLOR_BTN_GHOST_BORDER: string = 'rgba(255, 255, 255, 0.14)';
|
||||
export const COLOR_SAVE_BTN_BORDER: string = '#D99A2B';
|
||||
|
||||
export const COLOR_TOAST_BG: string = 'rgba(23, 169, 100, 0.16)';
|
||||
export const COLOR_TOAST_TEXT: string = '#6EE7A8';
|
||||
export const COLOR_TOAST_ERROR_BG: string = 'rgba(232, 64, 38, 0.18)';
|
||||
export const COLOR_TOAST_ERROR_TEXT: string = '#F0865B';
|
||||
export const COLOR_TOAST_WARN_BG: string = 'rgba(217, 154, 43, 0.16)';
|
||||
export const COLOR_TOAST_WARN_TEXT: string = '#FCD9A0';
|
||||
|
||||
// ===== radius (style.css --radius-*) =====
|
||||
export const RADIUS_SM: number = 6;
|
||||
export const RADIUS_MD: number = 10;
|
||||
export const RADIUS_LG: number = 14;
|
||||
export const RADIUS_PILL: number = 999;
|
||||
|
||||
// ===== misc states =====
|
||||
export const COLOR_CONN_OFFLINE: string = '#77809A';
|
||||
export const COLOR_DOT_GRAY: string = '#475569';
|
||||
|
||||
// =====================================================================
|
||||
// Global reactive theme store.
|
||||
//
|
||||
// AppStorage keys:
|
||||
// "themeIsDark" boolean — current resolved dark/light state
|
||||
// "themeMode" string — 'system' | 'dark' | 'light'
|
||||
//
|
||||
// Pages read via @StorageProp("themeIsDark") and pick colors from
|
||||
// tp() so the whole tree re-renders when the mode flips.
|
||||
// EntryAbility seeds both on launch and updates "themeIsDark" on
|
||||
// onConfigurationUpdate (system dark-mode change).
|
||||
// =====================================================================
|
||||
|
||||
const KEY_THEME_IS_DARK: string = 'themeIsDark';
|
||||
const KEY_SYSTEM_IS_DARK: string = 'systemIsDark';
|
||||
const KEY_THEME_MODE: string = 'themeMode';
|
||||
|
||||
export function seedTheme(isDark: boolean): void {
|
||||
if (!AppStorage.has(KEY_THEME_IS_DARK)) {
|
||||
AppStorage.setOrCreate(KEY_THEME_IS_DARK, isDark);
|
||||
} else {
|
||||
AppStorage.set(KEY_THEME_IS_DARK, isDark);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the OS dark/light state. UI never binds to this directly, but the
|
||||
* 'system' mode resolves against it, so EntryAbility updates it on config
|
||||
* change and then flips themeIsDark when mode === 'system'.
|
||||
*/
|
||||
export function seedSystemIsDark(isDark: boolean): void {
|
||||
if (!AppStorage.has(KEY_SYSTEM_IS_DARK)) {
|
||||
AppStorage.setOrCreate(KEY_SYSTEM_IS_DARK, isDark);
|
||||
} else {
|
||||
AppStorage.set(KEY_SYSTEM_IS_DARK, isDark);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stored theme mode ('system'|'dark'|'light') against the current
|
||||
* system color mode. 'system' falls back to the systemIsDark flag.
|
||||
*/
|
||||
export function resolveIsDark(mode: string, sysDark: boolean): boolean {
|
||||
if (mode === 'dark') {
|
||||
return true;
|
||||
}
|
||||
if (mode === 'light') {
|
||||
return false;
|
||||
}
|
||||
return sysDark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a stored theme mode: persist the mode token and immediately flip the
|
||||
* reactive themeIsDark flag so the whole UI re-renders.
|
||||
*/
|
||||
export function applyThemeMode(mode: string): void {
|
||||
const sysDark: boolean = AppStorage.get<boolean>(KEY_SYSTEM_IS_DARK) ?? true;
|
||||
AppStorage.set(KEY_THEME_MODE, mode);
|
||||
seedTheme(resolveIsDark(mode, sysDark));
|
||||
}
|
||||
|
||||
/** Current resolved palette for @Builder / build() usage. */
|
||||
export function tp(): ThemePalette {
|
||||
const dark: boolean = AppStorage.get<boolean>(KEY_THEME_IS_DARK) ?? true;
|
||||
return dark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/** Raw read for non-UI code. */
|
||||
export function themeIsDark(): boolean {
|
||||
return AppStorage.get<boolean>(KEY_THEME_IS_DARK) ?? true;
|
||||
}
|
||||
296
cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets
Normal file
296
cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets
Normal file
@ -0,0 +1,296 @@
|
||||
import { webSocket } from '@kit.NetworkKit';
|
||||
import { CapResult } from './BridgeCaps';
|
||||
import { BridgeCmdHandler, bridgeHelloFrame, bridgeBindFrame, bridgeResultFrame,
|
||||
bridgeDataStartFrame, bridgeDataEndFrame, bridgeEventFrame, bridgeStatusFrame,
|
||||
bridgeChunkSlices } from './BridgeProtocol';
|
||||
|
||||
export class DeviceBridgeClient {
|
||||
private ws: webSocket.WebSocket = webSocket.createWebSocket();
|
||||
private url: string = '';
|
||||
private token: string = '';
|
||||
private deviceId: string = '';
|
||||
private name: string = 'HomeAgent OHOS';
|
||||
private kind: string = 'phone';
|
||||
private caps: string[] = [];
|
||||
private hostname: string = 'ohos';
|
||||
private connected: boolean = false;
|
||||
private bound: boolean = false;
|
||||
private manualClose: boolean = false;
|
||||
private reconnectTimer: number = -1;
|
||||
private connectionGeneration: number = 0;
|
||||
private cmdHandler: BridgeCmdHandler | null = null;
|
||||
private onStateChange: ((open: boolean) => void) | null = null;
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected && this.bound;
|
||||
}
|
||||
|
||||
getDeviceId(): string {
|
||||
return this.deviceId;
|
||||
}
|
||||
|
||||
setCmdHandler(handler: BridgeCmdHandler): void {
|
||||
this.cmdHandler = handler;
|
||||
}
|
||||
|
||||
setStateListener(listener: (open: boolean) => void): void {
|
||||
this.onStateChange = listener;
|
||||
}
|
||||
|
||||
async connect(url: string, token: string, deviceId: string,
|
||||
caps: string[], hostname: string,
|
||||
authorized: boolean, name: string): Promise<void> {
|
||||
this.url = url;
|
||||
this.token = token;
|
||||
this.deviceId = deviceId;
|
||||
this.caps = caps;
|
||||
this.hostname = hostname;
|
||||
this.name = name;
|
||||
this.manualClose = false;
|
||||
await this.openAndRegister(authorized);
|
||||
}
|
||||
|
||||
private async openAndRegister(authorized: boolean): Promise<void> {
|
||||
try {
|
||||
this.ws.off('open');
|
||||
this.ws.off('message');
|
||||
this.ws.off('close');
|
||||
this.ws.off('error');
|
||||
this.ws.close().catch(() => {
|
||||
// ignore stale socket close failure
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore stale socket cleanup failure
|
||||
}
|
||||
this.connectionGeneration = this.connectionGeneration + 1;
|
||||
const generation: number = this.connectionGeneration;
|
||||
const socket: webSocket.WebSocket = webSocket.createWebSocket();
|
||||
this.ws = socket;
|
||||
this.connected = false;
|
||||
this.bound = false;
|
||||
this.bindWsEvents(socket, authorized, generation);
|
||||
const opts: webSocket.WebSocketRequestOptions = {
|
||||
header: this.authHeader(),
|
||||
};
|
||||
try {
|
||||
await socket.connect(this.url, opts);
|
||||
} catch (e) {
|
||||
if (generation === this.connectionGeneration && !this.manualClose) {
|
||||
this.connected = false;
|
||||
this.bound = false;
|
||||
this.notifyState(false);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** WebUI 用 API key 验证外层连接,并由反代向设备网关注入其内部 token。 */
|
||||
private authHeader(): Record<string, string> {
|
||||
const h: Record<string, string> = {};
|
||||
if (this.token.length > 0) {
|
||||
h['X-API-Key'] = this.token;
|
||||
h['Authorization'] = 'Bearer ' + this.token;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
private bindWsEvents(socket: webSocket.WebSocket, authorized: boolean, generation: number): void {
|
||||
socket.on('open', (err: Error, value: Object) => {
|
||||
if (generation !== this.connectionGeneration || this.manualClose) {
|
||||
socket.close().catch(() => {
|
||||
// ignore stale socket close failure
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.connected = true;
|
||||
this.bound = false;
|
||||
this.sendHello(authorized);
|
||||
this.sendBind();
|
||||
});
|
||||
socket.on('message', (err: Error, value: string | ArrayBuffer) => {
|
||||
if (generation === this.connectionGeneration && typeof value === 'string') {
|
||||
this.handleTextFrame(value);
|
||||
}
|
||||
});
|
||||
socket.on('close', (err: Error, value: webSocket.CloseResult) => {
|
||||
this.handleSocketEnd(generation);
|
||||
});
|
||||
socket.on('error', (err: Error) => {
|
||||
this.handleSocketEnd(generation);
|
||||
});
|
||||
}
|
||||
|
||||
private handleSocketEnd(generation: number): void {
|
||||
if (generation !== this.connectionGeneration) {
|
||||
return;
|
||||
}
|
||||
this.connected = false;
|
||||
this.bound = false;
|
||||
this.notifyState(false);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private notifyState(open: boolean): void {
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(open);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.manualClose || this.reconnectTimer >= 0) {
|
||||
return;
|
||||
}
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = -1;
|
||||
if (this.manualClose || this.url.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.openAndRegister(this.lastAuthorized);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private lastAuthorized: boolean = false;
|
||||
|
||||
private cancelReconnect(): void {
|
||||
if (this.reconnectTimer >= 0) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新本地授权状态并在已绑定连接上同步到服务端。 */
|
||||
updateAuthorized(authorized: boolean): void {
|
||||
this.lastAuthorized = authorized;
|
||||
if (this.connected && this.bound) {
|
||||
this.sendHello(authorized);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.manualClose = true;
|
||||
this.connectionGeneration = this.connectionGeneration + 1;
|
||||
this.cancelReconnect();
|
||||
this.connected = false;
|
||||
this.bound = false;
|
||||
try {
|
||||
this.ws.off('open');
|
||||
this.ws.off('message');
|
||||
this.ws.off('close');
|
||||
this.ws.off('error');
|
||||
this.ws.close().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
this.notifyState(false);
|
||||
}
|
||||
|
||||
private sendHello(authorized: boolean): void {
|
||||
this.lastAuthorized = authorized;
|
||||
this.send(bridgeHelloFrame(this.deviceId, this.name, this.kind, this.caps,
|
||||
this.hostname, authorized));
|
||||
}
|
||||
|
||||
private sendBind(): void {
|
||||
this.send(bridgeBindFrame(this.deviceId, this.token));
|
||||
}
|
||||
|
||||
// ===== 命令处理 =====
|
||||
|
||||
private handleTextFrame(text: string): void {
|
||||
let obj: Record<string, Object>;
|
||||
try {
|
||||
obj = JSON.parse(text) as Record<string, Object>;
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
const op: string = obj['op'] as string ?? '';
|
||||
if (op === 'bind_ack') {
|
||||
const accepted: boolean = obj['ok'] === true;
|
||||
if (accepted && this.connected && !this.manualClose) {
|
||||
this.bound = true;
|
||||
this.cancelReconnect();
|
||||
this.notifyState(true);
|
||||
} else {
|
||||
this.bound = false;
|
||||
this.notifyState(false);
|
||||
try {
|
||||
this.ws.close().catch(() => {
|
||||
// ignore bind rejection close failure
|
||||
});
|
||||
} catch (e) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
private dispatchCommand(reqId: string, command: string): void {
|
||||
if (this.cmdHandler === null) {
|
||||
this.sendResult(reqId, 'error', '', '本机能力尚未就绪,请保持应用在前台后重试');
|
||||
return;
|
||||
}
|
||||
const handler: BridgeCmdHandler = this.cmdHandler;
|
||||
handler(reqId, command).then((res: CapResult) => {
|
||||
this.sendResult(reqId, res.status, res.output, res.error);
|
||||
}).catch((e: Object) => {
|
||||
this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');
|
||||
});
|
||||
}
|
||||
|
||||
sendResult(reqId: string, status: string, output: string, errMsg: string): void {
|
||||
this.send(bridgeResultFrame(reqId, status, output, errMsg));
|
||||
}
|
||||
|
||||
// ===== 二进制分块回传(协议与 GUI 客户端一致)=====
|
||||
|
||||
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
|
||||
this.send(bridgeDataStartFrame(reqId, kind, mime, bytes.byteLength));
|
||||
const chunks: Uint8Array[] = bridgeChunkSlices(bytes);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const ab: ArrayBuffer = chunks[i].buffer as ArrayBuffer;
|
||||
try {
|
||||
this.ws.send(ab).catch(() => {
|
||||
// ignore per-chunk failure; end frame reports error below
|
||||
});
|
||||
} catch (e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.send(bridgeDataEndFrame(reqId));
|
||||
}
|
||||
|
||||
sendEvent(eventType: string, detail: string): void {
|
||||
this.send(bridgeEventFrame(this.deviceId, eventType, detail));
|
||||
}
|
||||
|
||||
sendStatus(status: string): void {
|
||||
this.send(bridgeStatusFrame(this.deviceId, status));
|
||||
}
|
||||
|
||||
send(text: string): void {
|
||||
if (!this.connected) {
|
||||
return;
|
||||
}
|
||||
this.ws.send(text).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
|
||||
@ -0,0 +1,105 @@
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { deviceBridge } from './DeviceBridge';
|
||||
import { installCmdRouter, setBridgeAppContext } from './BridgeRouter';
|
||||
import { LOCAL_DEVICE_CAPS, shutdownSpeakerUse } from './BridgeCaps';
|
||||
import { connStore } from './ConnStore';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
|
||||
export { LOCAL_DEVICE_CAPS } from './BridgeCaps';
|
||||
|
||||
let bridgeStarting: boolean = false;
|
||||
let foregroundActive: boolean = false;
|
||||
let rootUIReady: boolean = false;
|
||||
let bridgeGeneration: number = 0;
|
||||
let stateTrackingReady: boolean = false;
|
||||
|
||||
function ensureBridgeStateTracking(): void {
|
||||
if (stateTrackingReady) {
|
||||
return;
|
||||
}
|
||||
stateTrackingReady = true;
|
||||
AppStorage.setOrCreate<boolean>('deviceBridgeConnected', false);
|
||||
deviceBridge.setStateListener((open: boolean) => {
|
||||
AppStorage.set<boolean>('deviceBridgeConnected', open);
|
||||
});
|
||||
}
|
||||
|
||||
/** 把当前后端 HTTP 地址转换为同源设备桥 WebSocket 地址。 */
|
||||
export function deviceGatewayUrl(base: string): string {
|
||||
let trimmed: string = base.trim();
|
||||
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
||||
trimmed = trimmed.substring(0, trimmed.length - 1);
|
||||
}
|
||||
let scheme: string = 'ws://';
|
||||
let rest: string = trimmed;
|
||||
if (trimmed.startsWith('https://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('https://'.length);
|
||||
} else if (trimmed.startsWith('http://')) {
|
||||
rest = trimmed.substring('http://'.length);
|
||||
} else if (trimmed.startsWith('wss://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('wss://'.length);
|
||||
} else if (trimmed.startsWith('ws://')) {
|
||||
rest = trimmed.substring('ws://'.length);
|
||||
}
|
||||
return scheme + rest + '/api/v1/device/ws';
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用进入前台后建立全局设备桥。它不再依赖用户先打开“设备”Tab,
|
||||
* 因而 screensue、clipboardsee 等前台能力从主页面加载后即可接收。
|
||||
*/
|
||||
export async function startForegroundBridge(context: common.UIAbilityContext): Promise<void> {
|
||||
foregroundActive = true;
|
||||
setBridgeAppContext(context);
|
||||
installCmdRouter();
|
||||
ensureBridgeStateTracking();
|
||||
if (!rootUIReady || deviceBridge.isConnected() || bridgeStarting) {
|
||||
return;
|
||||
}
|
||||
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||
if (cur === null || cur.url.length === 0 || cur.apiKey.length === 0) {
|
||||
return;
|
||||
}
|
||||
bridgeStarting = true;
|
||||
bridgeGeneration = bridgeGeneration + 1;
|
||||
const generation: number = bridgeGeneration;
|
||||
const deviceId: string = connStore.ensureDeviceId();
|
||||
try {
|
||||
await deviceBridge.connect(
|
||||
deviceGatewayUrl(cur.url), cur.apiKey, deviceId,
|
||||
LOCAL_DEVICE_CAPS, 'ohos-phone', connStore.getDeviceAuth(), connStore.getDeviceName());
|
||||
if (!foregroundActive || generation !== bridgeGeneration) {
|
||||
deviceBridge.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
// DeviceBridge 自己会安排重连;前台启动不弹技术错误打扰用户。
|
||||
}
|
||||
if (generation === bridgeGeneration) {
|
||||
bridgeStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 根页面挂载完成后才连接,避免首条 screensue 到达时展示层尚未注册。 */
|
||||
export function markForegroundBridgeUIReady(context: common.UIAbilityContext): void {
|
||||
rootUIReady = true;
|
||||
startForegroundBridge(context);
|
||||
}
|
||||
|
||||
/** 后台不接受需要前台 UI/剪贴板授权的命令。 */
|
||||
export function stopForegroundBridge(): void {
|
||||
foregroundActive = false;
|
||||
bridgeGeneration = bridgeGeneration + 1;
|
||||
bridgeStarting = false;
|
||||
shutdownSpeakerUse();
|
||||
deviceBridge.disconnect();
|
||||
}
|
||||
|
||||
/** 连接配置切换或修改后立即让设备桥使用新地址和 Token。 */
|
||||
export async function restartForegroundBridge(context: common.UIAbilityContext): Promise<void> {
|
||||
bridgeGeneration = bridgeGeneration + 1;
|
||||
bridgeStarting = false;
|
||||
deviceBridge.disconnect();
|
||||
await startForegroundBridge(context);
|
||||
}
|
||||
73
cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceModel.ets
Normal file
73
cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceModel.ets
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 设备页的纯逻辑:本机 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;
|
||||
}
|
||||
282
cmd/ohos/HomeAgent/entry/src/main/ets/common/MarkdownParser.ets
Normal file
282
cmd/ohos/HomeAgent/entry/src/main/ets/common/MarkdownParser.ets
Normal file
@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 导航栏显隐控制器(跨页面共享单例)。
|
||||
* 规则:任何页面滚动中隐藏底部导航,滚动停止(松手/fling 结束)后重新显示。
|
||||
*/
|
||||
type NavListener = (visible: boolean) => void;
|
||||
|
||||
export class NavBarController {
|
||||
private static instance: NavBarController | null = null;
|
||||
private visible: boolean = true;
|
||||
private listeners: NavListener[] = [];
|
||||
|
||||
static shared(): NavBarController {
|
||||
if (NavBarController.instance === null) {
|
||||
NavBarController.instance = new NavBarController();
|
||||
}
|
||||
return NavBarController.instance;
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.visible;
|
||||
}
|
||||
|
||||
setVisible(v: boolean): void {
|
||||
if (this.visible !== v) {
|
||||
this.visible = v;
|
||||
AppStorage.setOrCreate<boolean>('navVisible', v);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
addListener(l: NavListener): void {
|
||||
this.listeners.push(l);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (let i = 0; i < this.listeners.length; i++) {
|
||||
this.listeners[i](this.visible);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const navBar: NavBarController = NavBarController.shared();
|
||||
|
||||
/**
|
||||
* 页面 Scroll.onDidScroll 的统一处理:
|
||||
* Scroll/Fling 状态(手指拖动或惯性滚动)隐藏导航栏,
|
||||
* Idle(松手或惯性结束)重新显示。
|
||||
*/
|
||||
export function handleNavOnScroll(state: ScrollState): void {
|
||||
if (state === ScrollState.Idle) {
|
||||
navBar.setVisible(true);
|
||||
} else {
|
||||
navBar.setVisible(false);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 各主 Tab 页面的二级导航栈登记处。
|
||||
*
|
||||
* 为什么需要它:四个一级页面各自持有一个 Navigation(宽屏要"左一级右二级",
|
||||
* 所以栈必须是页面局部的,不能提到 Index 里去)。但四个 Navigation 同时
|
||||
* 挂在 Swiper 里都是活的,系统返回事件落到哪一个并不确定 —— 表现就是
|
||||
* "有的页面返回手势能用、有的不能"。
|
||||
*
|
||||
* 解决办法:页面把自己的栈按 Tab 序号登记进来,@Entry 页在 onBackPress 里
|
||||
* 按当前 Tab 精确地 pop 对应的栈。这样返回手势/三键返回/无障碍返回
|
||||
* 在每个页面上的行为都是确定的。
|
||||
*/
|
||||
|
||||
interface StackEntry {
|
||||
stack: NavPathStack;
|
||||
/** pop 之后页面要同步自己的选中态(宽屏高亮、activeXxx 等) */
|
||||
onPopped: () => void;
|
||||
}
|
||||
|
||||
const registry: Map<number, StackEntry> = new Map<number, StackEntry>();
|
||||
|
||||
/** 页面 aboutToAppear 时登记;同一 Tab 重复登记以最后一次为准。 */
|
||||
export function registerNavStack(tab: number, stack: NavPathStack, onPopped: () => void): void {
|
||||
registry.set(tab, { stack: stack, onPopped: onPopped });
|
||||
}
|
||||
|
||||
export function unregisterNavStack(tab: number): void {
|
||||
registry.delete(tab);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回键/返回手势的统一处理。
|
||||
* 返回 true 表示已消费(弹出了一层二级页面),false 表示交回系统(退出应用)。
|
||||
*
|
||||
* 宽屏 Split 模式下右栏常驻,返回不该把它清空,所以那时直接不消费。
|
||||
*/
|
||||
export function handleBackPress(tab: number, isWide: boolean): boolean {
|
||||
if (isWide) {
|
||||
return false;
|
||||
}
|
||||
const e: StackEntry | undefined = registry.get(tab);
|
||||
if (e === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (e.stack.size() <= 0) {
|
||||
return false;
|
||||
}
|
||||
e.stack.pop();
|
||||
e.onPopped();
|
||||
return true;
|
||||
}
|
||||
174
cmd/ohos/HomeAgent/entry/src/main/ets/common/PluginApi.ets
Normal file
174
cmd/ohos/HomeAgent/entry/src/main/ets/common/PluginApi.ets
Normal file
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 插件数据获取(无 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;
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 插件状态展示口径(纯函数,无 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(' · ');
|
||||
}
|
||||
313
cmd/ohos/HomeAgent/entry/src/main/ets/common/SettingsModel.ets
Normal file
313
cmd/ohos/HomeAgent/entry/src/main/ets/common/SettingsModel.ets
Normal file
@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 设置数据模型与纯解析逻辑(无 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';
|
||||
}
|
||||
258
cmd/ohos/HomeAgent/entry/src/main/ets/common/SseClient.ets
Normal file
258
cmd/ohos/HomeAgent/entry/src/main/ets/common/SseClient.ets
Normal file
@ -0,0 +1,258 @@
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
|
||||
export interface SseEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type SseHandler = (ev: SseEvent) => void;
|
||||
export type SseCloseHandler = () => void;
|
||||
export type SseOpenHandler = () => void;
|
||||
|
||||
function decodeUtf8(bytes: Uint8Array): string {
|
||||
let result: string = '';
|
||||
let i: number = 0;
|
||||
while (i < bytes.length) {
|
||||
const b: number = bytes[i];
|
||||
if (b < 0x80) {
|
||||
result += String.fromCharCode(b);
|
||||
i++;
|
||||
} else if (b < 0xC0) {
|
||||
i++;
|
||||
} else if (b < 0xE0) {
|
||||
if (i + 1 < bytes.length) {
|
||||
result += String.fromCharCode(((b & 0x1F) << 6) | (bytes[i + 1] & 0x3F));
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else if (b < 0xF0) {
|
||||
if (i + 2 < bytes.length) {
|
||||
result += String.fromCharCode(
|
||||
((b & 0x0F) << 12) | ((bytes[i + 1] & 0x3F) << 6) | (bytes[i + 2] & 0x3F),
|
||||
);
|
||||
i += 3;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
if (i + 3 < bytes.length) {
|
||||
const cp: number =
|
||||
((b & 0x07) << 18) |
|
||||
((bytes[i + 1] & 0x3F) << 12) |
|
||||
((bytes[i + 2] & 0x3F) << 6) |
|
||||
(bytes[i + 3] & 0x3F);
|
||||
const adjusted: number = cp - 0x10000;
|
||||
result += String.fromCharCode(0xD800 + (adjusted >> 10));
|
||||
result += String.fromCharCode(0xDC00 + (adjusted & 0x3FF));
|
||||
i += 4;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class SseClient {
|
||||
private httpRequest: http.HttpRequest | null = null;
|
||||
private buffer: string = '';
|
||||
private lastEventId: string = '';
|
||||
private closed: boolean = false;
|
||||
private opened: boolean = false;
|
||||
private onEvent: SseHandler | null = null;
|
||||
private onClose: SseCloseHandler | null = null;
|
||||
private onOpen: SseOpenHandler | null = null;
|
||||
|
||||
setLastEventId(id: string): void {
|
||||
this.lastEventId = id;
|
||||
}
|
||||
|
||||
getLastEventId(): string {
|
||||
return this.lastEventId;
|
||||
}
|
||||
|
||||
async connect(conn: ConnectionConfig, path: string,
|
||||
onEvent: SseHandler, onClose: SseCloseHandler,
|
||||
onOpen: SseOpenHandler | null = null): Promise<void> {
|
||||
this.onEvent = onEvent;
|
||||
this.onClose = onClose;
|
||||
this.onOpen = onOpen;
|
||||
this.closed = false;
|
||||
this.opened = false;
|
||||
this.buffer = '';
|
||||
this.curEvent = '';
|
||||
this.curData = '';
|
||||
this.curId = '';
|
||||
|
||||
const base: string = conn.url.replace(/\/+$/, '');
|
||||
const url: string = base + '/api/v1' + path;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'text/event-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
};
|
||||
if (conn.apiKey.length > 0) {
|
||||
// 后端 validAPIKey 两种都认;有些反代只放行 Authorization,两个都带更稳
|
||||
headers['X-API-Key'] = conn.apiKey;
|
||||
headers['Authorization'] = 'Bearer ' + conn.apiKey;
|
||||
}
|
||||
if (this.lastEventId.length > 0) {
|
||||
headers['Last-Event-ID'] = this.lastEventId;
|
||||
}
|
||||
|
||||
const req: http.HttpRequest = http.createHttp();
|
||||
this.httpRequest = req;
|
||||
|
||||
req.on('headersReceive', (header: Object) => {
|
||||
const ct: string = this.getHeaderValue(header, 'content-type');
|
||||
if (ct.indexOf('text/event-stream') >= 0) {
|
||||
this.markOpened();
|
||||
} else {
|
||||
this.finish();
|
||||
}
|
||||
});
|
||||
|
||||
req.on('dataReceive', (chunk: ArrayBuffer) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.markOpened();
|
||||
const bytes: Uint8Array = new Uint8Array(chunk);
|
||||
const text: string = decodeUtf8(bytes);
|
||||
this.buffer += text;
|
||||
this.processBuffer();
|
||||
});
|
||||
|
||||
req.on('dataEnd', () => {
|
||||
this.finish();
|
||||
});
|
||||
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.GET,
|
||||
header: headers,
|
||||
expectDataType: http.HttpDataType.ARRAY_BUFFER,
|
||||
usingCache: false,
|
||||
readTimeout: 3600000,
|
||||
connectTimeout: 15000,
|
||||
};
|
||||
|
||||
try {
|
||||
await req.requestInStream(url, options);
|
||||
} catch (e) {
|
||||
this.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private getHeaderValue(header: Object, name: string): string {
|
||||
try {
|
||||
const rec: Record<string, string> = header as Record<string, string>;
|
||||
const lower: string = name.toLowerCase();
|
||||
const keys: string[] = Object.keys(rec);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (keys[i].toLowerCase() === lower) {
|
||||
const val: string | undefined = rec[keys[i]];
|
||||
return val !== undefined ? val : '';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 帧解析状态必须【跨 chunk 保持】。
|
||||
*
|
||||
* 之前把 eventType/data/id 作为 processBuffer 的局部变量,
|
||||
* 而 TCP 分片完全可能切在帧内部的换行处(服务端 16ms 批量 flush 时
|
||||
* 一次写入几十帧,尾部被切开是常态):
|
||||
* chunk1 = "...event: content_delta\n"
|
||||
* chunk2 = "data: {...}\n\n"
|
||||
* 于是 chunk1 解析出的 event 被丢掉,chunk2 只剩 data 而没有事件名,
|
||||
* 整帧被静默丢弃 —— 表现就是"工具调用和思考不显示、也不是流式"。
|
||||
*/
|
||||
private curEvent: string = '';
|
||||
private curData: string = '';
|
||||
private curId: string = '';
|
||||
|
||||
private processBuffer(): void {
|
||||
const lines: string[] = this.buffer.split('\n');
|
||||
this.buffer = lines.pop() ?? '';
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
// 兼容 CRLF:\r 会污染事件名与 JSON 尾部
|
||||
let line: string = lines[i];
|
||||
if (line.length > 0 && line.charAt(line.length - 1) === '\r') {
|
||||
line = line.substring(0, line.length - 1);
|
||||
}
|
||||
if (line.startsWith(':')) {
|
||||
// 注释行(心跳),忽略
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('id:')) {
|
||||
this.curId = line.substring(3).trim();
|
||||
if (this.curId.length > 0) {
|
||||
this.lastEventId = this.curId;
|
||||
}
|
||||
} else if (line.startsWith('event:')) {
|
||||
this.curEvent = line.substring(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
// SSE 规范:data: 后的单个空格属于分隔符,其余原样保留;
|
||||
// 多行 data 用换行拼接。
|
||||
let chunk: string = line.substring(5);
|
||||
if (chunk.startsWith(' ')) {
|
||||
chunk = chunk.substring(1);
|
||||
}
|
||||
this.curData = this.curData.length > 0 ? this.curData + '\n' + chunk : chunk;
|
||||
} else if (line === '') {
|
||||
if (this.curEvent.length > 0 && this.curData.length > 0) {
|
||||
const ev: SseEvent = { event: this.curEvent, data: this.curData, id: this.curId };
|
||||
if (this.onEvent !== null) {
|
||||
this.onEvent(ev);
|
||||
}
|
||||
}
|
||||
this.curEvent = '';
|
||||
this.curData = '';
|
||||
this.curId = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.opened = false;
|
||||
if (this.httpRequest !== null) {
|
||||
try {
|
||||
this.httpRequest.off('dataReceive');
|
||||
this.httpRequest.off('dataEnd');
|
||||
this.httpRequest.off('headersReceive');
|
||||
this.httpRequest.destroy();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
this.httpRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
private markOpened(): void {
|
||||
if (this.opened || this.closed) {
|
||||
return;
|
||||
}
|
||||
this.opened = true;
|
||||
if (this.onOpen !== null) {
|
||||
this.onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
private finish(): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
if (this.onClose !== null) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
180
cmd/ohos/HomeAgent/entry/src/main/ets/common/StageTrail.ets
Normal file
180
cmd/ohos/HomeAgent/entry/src/main/ets/common/StageTrail.ets
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 阶段轨迹(单例):本轮对话在七个内核阶段里真实发生过什么。
|
||||
*
|
||||
* 由 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;
|
||||
}
|
||||
502
cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets
Normal file
502
cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets
Normal file
@ -0,0 +1,502 @@
|
||||
import { apiClient } from './ApiClient';
|
||||
import { userMessage, noConnectionMessage } from './UserError';
|
||||
|
||||
/**
|
||||
* 运行状态数据源(单例)。
|
||||
*
|
||||
* 状态页已并入设置页:设置一级页顶部嵌一张摘要卡,明细走二级页。
|
||||
* 摘要卡与明细页是两个独立组件,但必须显示同一份数据、只请求一次,
|
||||
* 所以把请求与解析收拢到这里,标量通过 AppStorage 广播给两边。
|
||||
*
|
||||
* 字段口径与 WebGUI 的 概览/内核 两页一致:
|
||||
* - GET /status → status / version / startedAt / agents
|
||||
* - GET /kernel → agent_id / plugins / tools / llm / memory / documents /
|
||||
* text_memory / knowledge / runtime
|
||||
* 后端不提供 token 用量、配额、错误列表,所以这里也没有。
|
||||
*/
|
||||
|
||||
/** 一行明细 */
|
||||
export interface StatField {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** 一组明细卡 */
|
||||
export interface StatGroup {
|
||||
title: string;
|
||||
fields: StatField[];
|
||||
}
|
||||
|
||||
// ===== 运行态快照(/runtime)=====
|
||||
//
|
||||
// 与 StatGroup 的分工:明细卡回答「内核有哪些东西、多少」,
|
||||
// 运行态回答「现在在干什么」——四级中断队列积压多少、有几个驻留子。
|
||||
// 两者数据源不同(/kernel vs /runtime),所以分开取、分开存。
|
||||
|
||||
/** 一条队列(四级中断之一,或排队队列) */
|
||||
export interface RuntimeQueue {
|
||||
/** 4/3/2/1;0 表示排队队列(无级别) */
|
||||
lv: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
depth: number;
|
||||
registered: number;
|
||||
preempted: number;
|
||||
}
|
||||
|
||||
/** 一次刷新的运行态快照 */
|
||||
export interface RuntimeSnapshot {
|
||||
/** 排队队列深度(无级别,纯 FIFO) */
|
||||
ready: number;
|
||||
pending: number;
|
||||
stack: number;
|
||||
maxStack: number;
|
||||
subagents: number;
|
||||
/** 格槽数(按全场最大深度缩放,至少 5) */
|
||||
slots: number;
|
||||
/** 五条队列:L4/L3/L2/L1 + 排队 */
|
||||
queues: RuntimeQueue[];
|
||||
}
|
||||
|
||||
interface LevelDef {
|
||||
lv: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/** 四级中断的定义(顺序即 L4→L1) */
|
||||
const LEVEL_DEFS: LevelDef[] = [
|
||||
{ lv: 4, name: 'L4', desc: '内核独占' },
|
||||
{ lv: 3, name: 'L3', desc: '交互' },
|
||||
{ lv: 2, name: 'L2', desc: '消息' },
|
||||
{ lv: 1, name: 'L1', desc: '后台' },
|
||||
];
|
||||
|
||||
/** JSON 数组里按下标取数:越界/类型不符都当 0 */
|
||||
function numAt(arr: Object[] | undefined, i: number): number {
|
||||
if (arr === undefined || arr === null || i < 0 || i >= arr.length) {
|
||||
return 0;
|
||||
}
|
||||
const v: number = arr[i] as number;
|
||||
return isNaN(v) ? 0 : v;
|
||||
}
|
||||
|
||||
/** 格槽数:按全场最大深度缩放,至少 5 格(0 时也要有可见形状)、最多 16 格 */
|
||||
function clampSlots(maxQ: number): number {
|
||||
if (maxQ < 5) {
|
||||
return 5;
|
||||
}
|
||||
return maxQ > 16 ? 16 : maxQ;
|
||||
}
|
||||
|
||||
// ===== AppStorage 键:摘要卡与明细页共用 =====
|
||||
export const K_UP: string = 'statUp';
|
||||
export const K_VERSION: string = 'statVersion';
|
||||
export const K_STARTED: string = 'statStartedAt';
|
||||
export const K_AGENTS: string = 'statAgents';
|
||||
export const K_PLUGINS: string = 'statPlugins';
|
||||
export const K_TOOLS: string = 'statTools';
|
||||
export const K_ERR: string = 'statErr';
|
||||
export const K_LOADING: string = 'statLoading';
|
||||
export const K_REV: string = 'statRev';
|
||||
/** 内核身份副行:内核名 · commit。版本号光有一个号码分不清是哪个内核、哪次构建。 */
|
||||
export const K_BUILD: string = 'statBuildSub';
|
||||
|
||||
class StatusStore {
|
||||
/** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */
|
||||
private groups: StatGroup[] = [];
|
||||
/** 运行态快照:同上,靠 K_REV 触发订阅组件重取 */
|
||||
private runtimeSnapshot: RuntimeSnapshot | undefined = undefined;
|
||||
|
||||
init(): void {
|
||||
AppStorage.setOrCreate<boolean>(K_UP, false);
|
||||
AppStorage.setOrCreate<string>(K_VERSION, '-');
|
||||
AppStorage.setOrCreate<string>(K_STARTED, '');
|
||||
AppStorage.setOrCreate<number>(K_AGENTS, 0);
|
||||
AppStorage.setOrCreate<number>(K_PLUGINS, 0);
|
||||
AppStorage.setOrCreate<number>(K_TOOLS, 0);
|
||||
AppStorage.setOrCreate<string>(K_ERR, '');
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||
AppStorage.setOrCreate<number>(K_REV, 0);
|
||||
AppStorage.setOrCreate<string>(K_BUILD, '');
|
||||
}
|
||||
|
||||
getGroups(): StatGroup[] {
|
||||
return this.groups;
|
||||
}
|
||||
|
||||
getRuntime(): RuntimeSnapshot | undefined {
|
||||
return this.runtimeSnapshot;
|
||||
}
|
||||
|
||||
isUp(): boolean {
|
||||
return AppStorage.get<boolean>(K_UP) ?? false;
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (!apiClient.hasConnection()) {
|
||||
this.fail(noConnectionMessage());
|
||||
return;
|
||||
}
|
||||
// setOrCreate 只负责首次建键,键已存在时不会覆盖旧值。
|
||||
// init() 已把所有键种好,刷新阶段必须用 set,否则摘要卡会永远停在
|
||||
// 版本 "-"、插件 0 的初始状态。
|
||||
AppStorage.set<boolean>(K_LOADING, true);
|
||||
AppStorage.set<string>(K_ERR, '');
|
||||
try {
|
||||
const resp = await apiClient.getWithTimeout('/status', 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
|
||||
const version: string = obj['version'] as string ?? '未知';
|
||||
const status: string = obj['status'] as string ?? 'unknown';
|
||||
const uptimeStr: string = obj['uptime'] as string ?? '';
|
||||
const agents: number = obj['agents'] as number ?? 0;
|
||||
const startedAt: string = obj['startedAt'] as string ?? '';
|
||||
|
||||
AppStorage.set<boolean>(K_UP, true);
|
||||
AppStorage.set<string>(K_VERSION, version);
|
||||
AppStorage.set<number>(K_AGENTS, agents);
|
||||
AppStorage.set<string>(K_STARTED, startedAt);
|
||||
|
||||
const systemFields: StatField[] = [
|
||||
{ label: '版本', value: version },
|
||||
{ label: '运行状态', value: statusText(status) },
|
||||
{ label: '运行时长', value: uptimeStr.length > 0 ? uptimeStr : '-' },
|
||||
];
|
||||
if (startedAt.length > 0) {
|
||||
systemFields.push({ label: '启动时间', value: formatTime(startedAt) });
|
||||
}
|
||||
|
||||
const groups: StatGroup[] = [
|
||||
{ title: '系统概览', fields: systemFields },
|
||||
];
|
||||
|
||||
await this.collectKernel(groups);
|
||||
await this.collectRuntime();
|
||||
this.groups = groups;
|
||||
this.bump();
|
||||
} catch (e) {
|
||||
this.fail(userMessage('status.refresh', e));
|
||||
}
|
||||
AppStorage.set<boolean>(K_LOADING, false);
|
||||
}
|
||||
|
||||
/** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */
|
||||
private async collectKernel(groups: StatGroup[]): Promise<void> {
|
||||
try {
|
||||
const kResp = await apiClient.getWithTimeout('/kernel', 8000);
|
||||
const k: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
|
||||
|
||||
const agentId: string = k['agent_id'] as string ?? 'main';
|
||||
const startTime: string = k['start_time'] as string ?? '';
|
||||
const pluginsArr: Object[] | undefined = k['plugins'] as Object[];
|
||||
const toolsArr: Object[] | undefined = k['tools'] as Object[];
|
||||
const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0;
|
||||
const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0;
|
||||
|
||||
AppStorage.set<number>(K_PLUGINS, pluginCount);
|
||||
AppStorage.set<number>(K_TOOLS, toolCount);
|
||||
|
||||
const kernelFields: StatField[] = [
|
||||
{ label: 'Agent ID', value: agentId },
|
||||
{ label: '已加载插件', value: pluginCount.toString() },
|
||||
{ label: '工具数', value: toolCount.toString() },
|
||||
];
|
||||
if (startTime.length > 0) {
|
||||
kernelFields.push({ label: '内核启动', value: formatTime(startTime) });
|
||||
}
|
||||
|
||||
// 构建身份:版本 / commit / SDK 兼容都取自 /kernel 的 build(-ldflags 注入)。
|
||||
// 为何不能只用 /status 的 version:那里只有一个版本号,分不清是哪个内核、
|
||||
// 哪次构建;许可标识更是完全没有。
|
||||
const build: Record<string, Object> | undefined = k['build'] as Record<string, Object>;
|
||||
let lic: string = '';
|
||||
let licURL: string = '';
|
||||
let srcURL: string = '';
|
||||
if (build !== undefined && build !== null) {
|
||||
const bVer: string = build['version'] as string ?? '';
|
||||
const bCommit: string = build['commit'] as string ?? '';
|
||||
const bName: string = build['kernel_name'] as string ?? 'HomeAgent';
|
||||
const bSdk: string = build['sdk_compatible'] as string ?? '';
|
||||
const bTime: string = build['build_time'] as string ?? '';
|
||||
lic = build['license'] as string ?? '';
|
||||
licURL = build['license_url'] as string ?? '';
|
||||
srcURL = build['source_url'] as string ?? '';
|
||||
if (bVer.length > 0) {
|
||||
AppStorage.set<string>(K_VERSION, 'v' + bVer);
|
||||
kernelFields.push({ label: '内核版本', value: 'v' + bVer });
|
||||
}
|
||||
let sub: string = bName;
|
||||
if (bCommit.length > 0 && bCommit !== 'unknown') {
|
||||
sub = sub + ' · ' + bCommit.substring(0, 7);
|
||||
}
|
||||
AppStorage.set<string>(K_BUILD, sub);
|
||||
if (bCommit.length > 0) {
|
||||
kernelFields.push({ label: 'Commit', value: bCommit });
|
||||
}
|
||||
if (bSdk.length > 0) {
|
||||
kernelFields.push({ label: 'SDK 兼容', value: bSdk });
|
||||
}
|
||||
if (bTime.length > 0 && bTime !== 'unknown') {
|
||||
kernelFields.push({ label: '构建时间', value: formatTime(bTime) });
|
||||
}
|
||||
}
|
||||
groups.push({ title: '内核', fields: kernelFields });
|
||||
|
||||
// 开源许可:只给一个源码链接、不写协议名,使用者看不出这受什么许可约束,
|
||||
// 也看不出网络服务场景下的 §13 义务。
|
||||
const legalFields: StatField[] = [];
|
||||
if (lic.length > 0) {
|
||||
legalFields.push({ label: '许可协议', value: lic });
|
||||
}
|
||||
if (licURL.length > 0) {
|
||||
legalFields.push({ label: '协议全文', value: licURL });
|
||||
}
|
||||
if (srcURL.length > 0) {
|
||||
legalFields.push({ label: '源码仓库', value: srcURL });
|
||||
}
|
||||
if (legalFields.length > 0) {
|
||||
if (lic.toUpperCase().indexOf('AGPL') >= 0) {
|
||||
legalFields.push({
|
||||
label: '网络条款',
|
||||
value: '把修改后的版本作为网络服务对外提供时,必须向使用者提供取得对应源码的途径(§13)',
|
||||
});
|
||||
}
|
||||
groups.push({ title: '开源许可', fields: legalFields });
|
||||
}
|
||||
|
||||
// LLM:provider / 可用源 / 是否可用
|
||||
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
|
||||
if (llm !== undefined && llm !== null) {
|
||||
const provider: string = llm['provider'] as string ?? '';
|
||||
const sources: number = llm['sources'] as number ?? 0;
|
||||
const available: boolean = llm['available'] as boolean ?? false;
|
||||
groups.push({
|
||||
title: '模型',
|
||||
fields: [
|
||||
{ label: 'Provider', value: provider.length > 0 ? provider : '未配置' },
|
||||
{ label: '可用源', value: sources.toString() },
|
||||
{ label: '状态', value: available ? '运行中' : '不可用' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 记忆:图 / 文档 / 文本 / 知识库
|
||||
const memFields: StatField[] = [];
|
||||
const mem: Record<string, Object> | undefined = k['memory'] as Record<string, Object>;
|
||||
if (mem !== undefined && mem !== null) {
|
||||
const ok: boolean = mem['available'] as boolean ?? false;
|
||||
const ent: number = mem['entity_count'] as number ?? 0;
|
||||
const rel: number = mem['relation_count'] as number ?? 0;
|
||||
memFields.push({
|
||||
label: '图记忆',
|
||||
value: ok ? ent.toString() + ' 实体 · ' + rel.toString() + ' 关系' : '未初始化',
|
||||
});
|
||||
}
|
||||
const docs: Record<string, Object> | undefined = k['documents'] as Record<string, Object>;
|
||||
if (docs !== undefined && docs !== null) {
|
||||
const ok: boolean = docs['available'] as boolean ?? false;
|
||||
const n: number = docs['doc_count'] as number ?? 0;
|
||||
memFields.push({ label: '文档记忆', value: ok ? n.toString() + ' 文档' : '未初始化' });
|
||||
}
|
||||
const tm: Record<string, Object> | undefined = k['text_memory'] as Record<string, Object>;
|
||||
if (tm !== undefined && tm !== null) {
|
||||
const ok: boolean = tm['available'] as boolean ?? false;
|
||||
const n: number = tm['file_count'] as number ?? 0;
|
||||
memFields.push({ label: '文本记忆', value: ok ? n.toString() + ' 文件' : '未初始化' });
|
||||
}
|
||||
const kb: Record<string, Object> | undefined = k['knowledge'] as Record<string, Object>;
|
||||
if (kb !== undefined && kb !== null) {
|
||||
const ok: boolean = kb['available'] as boolean ?? false;
|
||||
const n: number = kb['item_count'] as number ?? 0;
|
||||
memFields.push({ label: '知识库', value: ok ? n.toString() + ' 项' : '未初始化' });
|
||||
}
|
||||
if (memFields.length > 0) {
|
||||
groups.push({ title: '记忆', fields: memFields });
|
||||
}
|
||||
|
||||
// 运行时:goroutine / 内存 / Go 版本
|
||||
const rt: Record<string, Object> | undefined = k['runtime'] as Record<string, Object>;
|
||||
if (rt !== undefined && rt !== null) {
|
||||
const g: number = rt['goroutines'] as number ?? 0;
|
||||
const mb: number = rt['memory_mb'] as number ?? 0;
|
||||
const gov: string = rt['go_version'] as string ?? '';
|
||||
groups.push({
|
||||
title: '运行时',
|
||||
fields: [
|
||||
{ label: 'Goroutines', value: g.toString() },
|
||||
{ label: '内存占用', value: mb.toString() + ' MB' },
|
||||
{ label: 'Go 版本', value: gov.length > 0 ? gov : '-' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 工具清单:名称 → 归属插件
|
||||
if (toolsArr !== undefined && toolsArr.length > 0) {
|
||||
const toolFields: StatField[] = [];
|
||||
for (let i = 0; i < toolsArr.length; i++) {
|
||||
const t: Record<string, Object> = toolsArr[i] as Record<string, Object>;
|
||||
const name: string = t['name'] as string ?? '';
|
||||
const plugin: string = t['plugin'] as string ?? '';
|
||||
if (name.length > 0) {
|
||||
toolFields.push({ label: name, value: plugin });
|
||||
}
|
||||
}
|
||||
if (toolFields.length > 0) {
|
||||
groups.push({ title: '可用工具', fields: toolFields });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// /kernel 不可用时只保留 /status 的概览分组
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取运行态快照(/runtime)。失败不影响已经取到的明细分组:
|
||||
* 旧后端可能没有这个端点,那时面板显示「运行态数据不可用」即可。
|
||||
*/
|
||||
private async collectRuntime(): Promise<void> {
|
||||
try {
|
||||
const resp = await apiClient.getWithTimeout('/runtime', 8000);
|
||||
const r: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
|
||||
const sc: Record<string, Object> | undefined = r['scheduler'] as Record<string, Object>;
|
||||
const residents: Object[] | undefined = r['residents'] as Object[];
|
||||
|
||||
let ready: number = 0;
|
||||
let pending: number = 0;
|
||||
let stack: number = 0;
|
||||
let maxStack: number = 4;
|
||||
let qArr: Object[] | undefined = undefined;
|
||||
let regArr: Object[] | undefined = undefined;
|
||||
let preArr: Object[] | undefined = undefined;
|
||||
if (sc !== undefined && sc !== null) {
|
||||
ready = sc['ready_queue_depth'] as number ?? 0;
|
||||
pending = sc['pending_interrupts'] as number ?? 0;
|
||||
stack = sc['suspend_stack'] as number ?? 0;
|
||||
maxStack = sc['max_suspend_depth'] as number ?? 4;
|
||||
qArr = sc['interrupt_queues'] as Object[];
|
||||
regArr = sc['interrupts_by_level'] as Object[];
|
||||
preArr = sc['preempts_by_level'] as Object[];
|
||||
}
|
||||
|
||||
// 四级语义引用内核的定义(internal/agent/core/scheduler.go),
|
||||
// 前端只负责把它们画出来,不自己起名字。
|
||||
const queues: RuntimeQueue[] = [];
|
||||
let maxQ: number = 1;
|
||||
for (let i = 0; i < LEVEL_DEFS.length; i++) {
|
||||
const d: LevelDef = LEVEL_DEFS[i];
|
||||
const depth: number = numAt(qArr, d.lv);
|
||||
if (depth > maxQ) {
|
||||
maxQ = depth;
|
||||
}
|
||||
queues.push({
|
||||
lv: d.lv,
|
||||
name: d.name,
|
||||
desc: d.desc,
|
||||
depth: depth,
|
||||
registered: numAt(regArr, d.lv),
|
||||
preempted: numAt(preArr, d.lv),
|
||||
});
|
||||
}
|
||||
if (ready > maxQ) {
|
||||
maxQ = ready;
|
||||
}
|
||||
// 第五条:排队队列。它不是优先级,而是另一**类别**(排队 vs 中断),
|
||||
// 所以 lv 用 0 标记「无级别」。
|
||||
queues.push({
|
||||
lv: 0,
|
||||
name: '排队',
|
||||
desc: 'FIFO',
|
||||
depth: ready,
|
||||
registered: 0,
|
||||
preempted: 0,
|
||||
});
|
||||
|
||||
const subagents: number = residents !== undefined && residents !== null
|
||||
? residents.length : 0;
|
||||
|
||||
this.runtimeSnapshot = {
|
||||
ready: ready,
|
||||
pending: pending,
|
||||
stack: stack,
|
||||
maxStack: maxStack,
|
||||
subagents: subagents,
|
||||
slots: clampSlots(maxQ),
|
||||
queues: queues,
|
||||
};
|
||||
} catch (e) {
|
||||
// /runtime 不可用(旧后端):保留上一次快照,不清空
|
||||
}
|
||||
}
|
||||
|
||||
private fail(msg: string): void {
|
||||
AppStorage.set<string>(K_ERR, msg);
|
||||
AppStorage.set<boolean>(K_UP, false);
|
||||
AppStorage.set<boolean>(K_LOADING, false);
|
||||
this.groups = [];
|
||||
this.runtimeSnapshot = undefined;
|
||||
this.bump();
|
||||
}
|
||||
|
||||
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
|
||||
private bump(): void {
|
||||
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
|
||||
AppStorage.set<number>(K_REV, cur + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export const statusStore: StatusStore = new StatusStore();
|
||||
|
||||
export function statusText(s: string): string {
|
||||
if (s === 'running') {
|
||||
return '运行中';
|
||||
}
|
||||
if (s === 'stopped') {
|
||||
return '已停止';
|
||||
}
|
||||
if (s === 'starting') {
|
||||
return '启动中';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function formatTime(iso: string): string {
|
||||
const t: number = new Date(iso).getTime();
|
||||
if (isNaN(t) || t <= 0) {
|
||||
return iso;
|
||||
}
|
||||
const d = new Date(t);
|
||||
return d.getFullYear().toString() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) +
|
||||
' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||||
}
|
||||
|
||||
/**
|
||||
* 紧凑运行时长:环心里只有 96vp 单行,必须用 12h48m 这种记法,
|
||||
* 「10时 49分 16秒」会折行。
|
||||
*/
|
||||
export function compactDuration(startedAt: string): string {
|
||||
if (startedAt.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
const t: number = new Date(startedAt).getTime();
|
||||
const now: number = Date.now();
|
||||
if (isNaN(t) || t <= 0 || now <= t) {
|
||||
return '-';
|
||||
}
|
||||
const sec: number = Math.floor((now - t) / 1000);
|
||||
const days: number = Math.floor(sec / 86400);
|
||||
const hours: number = Math.floor((sec % 86400) / 3600);
|
||||
const mins: number = Math.floor((sec % 3600) / 60);
|
||||
if (days > 0) {
|
||||
return days.toString() + 'd ' + hours.toString() + 'h';
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours.toString() + 'h ' + mins.toString() + 'm';
|
||||
}
|
||||
return mins.toString() + 'm ' + (sec % 60).toString() + 's';
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? '0' + n.toString() : n.toString();
|
||||
}
|
||||
178
cmd/ohos/HomeAgent/entry/src/main/ets/common/UserError.ets
Normal file
178
cmd/ohos/HomeAgent/entry/src/main/ets/common/UserError.ets
Normal file
@ -0,0 +1,178 @@
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { ApiError } from './ApiClient';
|
||||
|
||||
/**
|
||||
* 面向用户的错误文案统一出口。
|
||||
*
|
||||
* 问题背景:之前各页面直接把 `e.message` 拼进 UI,于是屏幕上出现
|
||||
* "获取状态失败: Failed to connect to the server."、原始 JSON 报错体、
|
||||
* 甚至后端堆栈。这类文本对用户没有意义,还会泄露内网地址与实现细节。
|
||||
*
|
||||
* 约定:
|
||||
* - UI 只显示 userMessage() 返回的短句(人话、可行动、不含技术细节);
|
||||
* - 技术细节(原始 message / HTTP 状态码 / 响应体)只写进 hilog,
|
||||
* 通过 `hdc shell hilog | grep HomeAgent` 排查,不进 UI。
|
||||
*/
|
||||
|
||||
const DOMAIN: number = 0xA0A0;
|
||||
const TAG: string = 'HomeAgent';
|
||||
|
||||
/** 网络类错误的统一提示:不暴露主机名、端口、curl 错误码。 */
|
||||
const MSG_UNREACHABLE: string = '连接不上后端服务,请检查网络与服务地址';
|
||||
const MSG_TIMEOUT: string = '后端响应超时,请稍后重试';
|
||||
const MSG_AUTH: string = 'API Key 无效或已过期,请在设置里更新';
|
||||
const MSG_FORBIDDEN: string = '没有访问权限,请检查 API Key 的权限范围';
|
||||
const MSG_NOT_FOUND: string = '后端没有这个接口,可能版本不匹配';
|
||||
const MSG_SERVER: string = '后端服务内部出错,请查看服务端日志';
|
||||
const MSG_BAD_DATA: string = '后端返回的数据无法解析';
|
||||
const MSG_TLS: string = '证书校验失败,请检查 HTTPS 配置';
|
||||
const MSG_GENERIC: string = '操作失败,请稍后重试';
|
||||
const MSG_NO_CONN: string = '尚未配置后端连接,请先在设置里添加';
|
||||
|
||||
/** 一眼判定是否"网络层根本没连上",用于页面显示离线态而不是报错态。 */
|
||||
export function isOffline(e: Object): boolean {
|
||||
const code: number = businessCode(e);
|
||||
if (code === 2300006 || code === 2300007 || code === 2300005 ||
|
||||
code === 2300052 || code === 2300056 || code === 2300028) {
|
||||
return true;
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
return e.status < 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为超时。聊天场景里超时不算失败——请求已经到后端,
|
||||
* 只是回复还没生成完,UI 要显示"等待回复"而不是报错。
|
||||
*/
|
||||
export function isTimeout(e: Object): boolean {
|
||||
const code: number = businessCode(e);
|
||||
if (code === 2300028) {
|
||||
return true;
|
||||
}
|
||||
if (e instanceof ApiError && (e.status === 408 || e.status === 504)) {
|
||||
return true;
|
||||
}
|
||||
const raw: string = rawMessage(e);
|
||||
return raw.indexOf('timeout') >= 0 || raw.indexOf('超时') >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把任意异常翻译成一句用户能看懂、且不含技术细节的话。
|
||||
* 同时把原始信息写入 hilog(scene 用于定位是哪个调用点)。
|
||||
*/
|
||||
export function userMessage(scene: string, e: Object): string {
|
||||
logDetail(scene, e);
|
||||
|
||||
if (e instanceof ApiError) {
|
||||
const st: number = e.status;
|
||||
if (st === 401) {
|
||||
return MSG_AUTH;
|
||||
}
|
||||
if (st === 403) {
|
||||
return MSG_FORBIDDEN;
|
||||
}
|
||||
if (st === 404) {
|
||||
return MSG_NOT_FOUND;
|
||||
}
|
||||
if (st === 408 || st === 504) {
|
||||
return MSG_TIMEOUT;
|
||||
}
|
||||
if (st >= 500) {
|
||||
return MSG_SERVER;
|
||||
}
|
||||
if (st < 0) {
|
||||
return MSG_NO_CONN;
|
||||
}
|
||||
if (st >= 400) {
|
||||
// 4xx 里后端通常给了业务原因,但不保证是人话,也可能带内部路径。
|
||||
// 只在明显短且不含技术噪音时透传,否则退回通用文案。
|
||||
return safeBackendReason(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const code: number = businessCode(e);
|
||||
if (code === 2300028) {
|
||||
return MSG_TIMEOUT;
|
||||
}
|
||||
if (code === 2300005 || code === 2300006 || code === 2300007 ||
|
||||
code === 2300052 || code === 2300055 || code === 2300056) {
|
||||
return MSG_UNREACHABLE;
|
||||
}
|
||||
if (code === 2300058 || code === 2300059 || code === 2300060 || code === 2300077) {
|
||||
return MSG_TLS;
|
||||
}
|
||||
if (code === 2300001 || code === 2300003) {
|
||||
return '服务地址格式不正确,请在设置里检查';
|
||||
}
|
||||
if (code === 2300009 || code === 2300094) {
|
||||
return MSG_AUTH;
|
||||
}
|
||||
if (code === 2300997 || code === 2300998) {
|
||||
return '系统禁止访问该地址,请改用 HTTPS 或放开域名白名单';
|
||||
}
|
||||
|
||||
if (e instanceof SyntaxError) {
|
||||
// JSON.parse 失败
|
||||
return MSG_BAD_DATA;
|
||||
}
|
||||
|
||||
const raw: string = rawMessage(e);
|
||||
if (raw.indexOf('timeout') >= 0 || raw.indexOf('超时') >= 0) {
|
||||
return MSG_TIMEOUT;
|
||||
}
|
||||
return MSG_GENERIC;
|
||||
}
|
||||
|
||||
/** 未配置连接时的统一文案,页面不要各写一份。 */
|
||||
export function noConnectionMessage(): string {
|
||||
return MSG_NO_CONN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 4xx 的后端说明只在"看起来是给人看的"时才透传:
|
||||
* 短、无换行、不含路径/括号异常/HTML/JSON 花括号。
|
||||
*/
|
||||
function safeBackendReason(msg: string): string {
|
||||
const s: string = msg.trim();
|
||||
if (s.length === 0 || s.length > 60) {
|
||||
return MSG_GENERIC;
|
||||
}
|
||||
if (s.indexOf('\n') >= 0 || s.indexOf('{') >= 0 || s.indexOf('<') >= 0 ||
|
||||
s.indexOf('/') >= 0 || s.indexOf('0x') >= 0 || s.indexOf('Exception') >= 0 ||
|
||||
s.indexOf('Error:') >= 0 || s.indexOf('panic') >= 0) {
|
||||
return MSG_GENERIC;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 取 BusinessError.code(ArkTS 不允许 in / 索引访问,用可选字段读取)。 */
|
||||
function businessCode(e: Object): number {
|
||||
const be = e as BusinessErrorLike;
|
||||
const c: number | undefined = be.code;
|
||||
return c !== undefined ? c : 0;
|
||||
}
|
||||
|
||||
function rawMessage(e: Object): string {
|
||||
if (e instanceof Error) {
|
||||
return e.message;
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
function logDetail(scene: string, e: Object): void {
|
||||
const code: number = businessCode(e);
|
||||
let detail: string = rawMessage(e);
|
||||
if (e instanceof ApiError) {
|
||||
detail = 'HTTP ' + e.status.toString() + ' ' + detail + ' body=' + e.body;
|
||||
}
|
||||
hilog.error(DOMAIN, TAG, '%{public}s failed: code=%{public}d detail=%{private}s',
|
||||
scene, code, detail);
|
||||
}
|
||||
|
||||
/** BusinessError 的最小结构(避免为了读 code 而 import 整个 kit)。 */
|
||||
interface BusinessErrorLike {
|
||||
code?: number;
|
||||
message?: string;
|
||||
}
|
||||
@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 「外观」二级页面:主题三选一 + 自定义背景图。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
343
cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets
Normal file
343
cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets
Normal file
@ -0,0 +1,343 @@
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { fileIo } from '@kit.CoreFileKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage } from '../common/UserError';
|
||||
import { ChatAttachment } from '../model/Model';
|
||||
import { extLabel, formatBytes, sanitize } from '../common/AttachmentMeta';
|
||||
import { loadPixelMap } from '../common/AttachmentImage';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { COLOR_ERROR } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { PlainCard } from './SubPage';
|
||||
|
||||
/**
|
||||
* 附件 UI:气泡内的附件卡 + 附件详情二级页内容。
|
||||
*
|
||||
* 解析/格式化(parseAttachment、formatBytes、extLabel…)在 common/AttachmentMeta.ets,
|
||||
* 字节获取与解码在 common/AttachmentImage.ets —— 这里只留 UI。
|
||||
*
|
||||
* 附件卡:图片显示缩略图,文件显示一枚文件条。
|
||||
* 点击进入附件详情二级页面(WebGUI 是新开标签页,移动端改为二级页)。
|
||||
*/
|
||||
@Component
|
||||
export struct AttachmentCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop att: ChatAttachment;
|
||||
@Prop mine: boolean = false;
|
||||
onTap?: () => void;
|
||||
@State private pixel: image.PixelMap | undefined = undefined;
|
||||
@State private failed: boolean = false;
|
||||
|
||||
aboutToAppear(): void {
|
||||
if (this.att.type === 'image') {
|
||||
this.loadThumb();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadThumb(): Promise<void> {
|
||||
const pm: image.PixelMap | undefined = await loadPixelMap(this.att.url);
|
||||
if (pm === undefined) {
|
||||
this.failed = true;
|
||||
return;
|
||||
}
|
||||
this.pixel = pm;
|
||||
}
|
||||
|
||||
build() {
|
||||
// 整卡按压反馈统一收进 MotionBase(父组件):scale 回弹 + 透明度压暗。
|
||||
// fillWidth: false —— 附件卡在聊天气泡内按内容自适应宽度,不能撑满整行。
|
||||
MotionBase({ pressEnabled: true, pressOpacity: 0.88, fillWidth: false }) {
|
||||
Column({ space: 6 }) {
|
||||
Text(this.mine ? '你发送的' : '小宅发送的')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
|
||||
if (this.att.type === 'image') {
|
||||
if (this.pixel !== undefined) {
|
||||
Image(this.pixel)
|
||||
.width('100%')
|
||||
.constraintSize({ maxHeight: 220 })
|
||||
.objectFit(ImageFit.Cover)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.draggable(false)
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.scale({ x: 0.98, y: 0.98 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
} else if (this.failed) {
|
||||
Row({ space: 6 }) {
|
||||
Image($r('app.media.ic_error'))
|
||||
.width(14)
|
||||
.height(14)
|
||||
.fillColor(COLOR_ERROR)
|
||||
.draggable(false)
|
||||
Text('图片加载失败')
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_ERROR)
|
||||
}
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
} else {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(20)
|
||||
.height(20)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width(120)
|
||||
.height(80)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
} else {
|
||||
Row({ space: 8 }) {
|
||||
Image($r('app.media.ic_file'))
|
||||
.width(18)
|
||||
.height(18)
|
||||
.fillColor(this.palette().accent)
|
||||
.draggable(false)
|
||||
Column({ space: 2 }) {
|
||||
Text(this.att.name)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (this.att.size > 0) {
|
||||
Text(formatBytes(this.att.size))
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(14)
|
||||
.height(14)
|
||||
.fillColor(this.palette().textMuted)
|
||||
.draggable(false)
|
||||
}
|
||||
.constraintSize({ minWidth: 180 })
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.mine ? this.palette().accentBg : this.palette().bgHover)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件详情二级页面内容:大图预览 / 文件信息 + 保存到本地。
|
||||
*
|
||||
* 后端不提供尺寸与 mime,图片尺寸由本地解码得到,类型标签由后缀推断,
|
||||
* 界面上如实标注它们的来源,不谎称是服务端元数据。
|
||||
*/
|
||||
@Component
|
||||
export struct AttachmentDetailContent {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop att: ChatAttachment;
|
||||
@State private pixel: image.PixelMap | undefined = undefined;
|
||||
@State private pxWidth: number = 0;
|
||||
@State private pxHeight: number = 0;
|
||||
@State private loading: boolean = true;
|
||||
@State private err: string = '';
|
||||
@State private savedPath: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
if (this.att.type === 'image') {
|
||||
this.loadFull();
|
||||
} else {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFull(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.err = '';
|
||||
try {
|
||||
const abs: string = apiClient.absoluteUrl(this.att.url);
|
||||
const resp = await apiClient.getBinary(abs, 20000);
|
||||
const src: image.ImageSource = image.createImageSource(resp.data);
|
||||
const info: image.ImageInfo = await src.getImageInfo();
|
||||
this.pxWidth = info.size.width;
|
||||
this.pxHeight = info.size.height;
|
||||
this.pixel = await src.createPixelMap();
|
||||
await src.release();
|
||||
} catch (e) {
|
||||
this.err = userMessage('attachment.load', e);
|
||||
}
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存到应用沙箱 files 目录(不申请媒体库权限,避免为了看一张图要授权相册)。 */
|
||||
private async saveToSandbox(): Promise<void> {
|
||||
try {
|
||||
const abs: string = apiClient.absoluteUrl(this.att.url);
|
||||
const resp = await apiClient.getBinary(abs, 30000);
|
||||
const ctx = getContext(this) as common.UIAbilityContext;
|
||||
const dest: string = ctx.filesDir + '/' + sanitize(this.att.name);
|
||||
const f = fileIo.openSync(dest,
|
||||
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
|
||||
fileIo.writeSync(f.fd, resp.data);
|
||||
fileIo.closeSync(f);
|
||||
this.savedPath = dest;
|
||||
} catch (e) {
|
||||
this.err = userMessage('attachment.save', e);
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.err.length > 0) {
|
||||
Row({ space: 8 }) {
|
||||
Image($r('app.media.ic_error'))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.fillColor(COLOR_ERROR)
|
||||
.draggable(false)
|
||||
Text(this.err)
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.margin({ bottom: 14 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: -12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
// 预览:图片铺满宽度可缩放查看;文件给一个大图标占位
|
||||
Column() {
|
||||
if (this.att.type === 'image') {
|
||||
if (this.loading) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(28)
|
||||
.height(28)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width('100%')
|
||||
.height(200)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
} else if (this.pixel !== undefined) {
|
||||
Image(this.pixel)
|
||||
.width('100%')
|
||||
.constraintSize({ maxHeight: 420 })
|
||||
.objectFit(ImageFit.Contain)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.draggable(false)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
} else {
|
||||
Column({ space: 10 }) {
|
||||
Image($r('app.media.ic_file'))
|
||||
.width(46)
|
||||
.height(46)
|
||||
.fillColor(this.palette().accent)
|
||||
.draggable(false)
|
||||
Text(extLabel(this.att.name))
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 26, bottom: 26 })
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.margin({ bottom: 14 })
|
||||
|
||||
PlainCard({ caption: '信息' }) {
|
||||
this.kv('文件名', this.att.name)
|
||||
this.kv('类型', this.att.type === 'image' ? '图片' : '文件')
|
||||
this.kv('格式', extLabel(this.att.name))
|
||||
this.kv('大小', this.att.size > 0 ? formatBytes(this.att.size) : '未知(远程链接)')
|
||||
if (this.pxWidth > 0 && this.pxHeight > 0) {
|
||||
// 后端不返回像素尺寸,这一行来自本地解码
|
||||
this.kv('像素', this.pxWidth.toString() + ' × ' + this.pxHeight.toString())
|
||||
}
|
||||
this.kv('来源', this.att.url.startsWith('http') ? '远程链接' : '服务端中转')
|
||||
}
|
||||
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Button('保存到应用目录')
|
||||
.width('100%')
|
||||
.height(38)
|
||||
.fontSize(13)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor('#FFFFFF')
|
||||
.onClick(() => {
|
||||
this.saveToSandbox();
|
||||
})
|
||||
if (this.savedPath.length > 0) {
|
||||
Text('已保存:' + this.savedPath)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 6 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
kv(label: string, value: string) {
|
||||
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: 210 })
|
||||
.margin({ left: 16 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 「核心配置」相关的两个二级页面:分类列表 + 某个分类的配置项。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 输入区上方的两个悬浮条:加号菜单(图片/文件)与待发送附件预览。
|
||||
*
|
||||
* 从 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
247
cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatBubble.ets
Normal file
247
cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatBubble.ets
Normal file
@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 单条聊天气泡(含头像、渠道名、思考卡、工具卡、附件卡、正文)。
|
||||
*
|
||||
* 从 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 })
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 悬浮输入区:加号菜单 + 待发送附件预览 + 输入行(含发送/中断)。
|
||||
*
|
||||
* 从 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%')
|
||||
}
|
||||
}
|
||||
}
|
||||
210
cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatStream.ets
Normal file
210
cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatStream.ets
Normal file
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 聊天消息流:列表 + 顶栏遮罩 + 底部淡出遮罩 + 滚动/懒加载。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出(原来是 ChatBody 里除悬浮输入区之外的三层)。
|
||||
* 消息数组来自 common/ChatStore.ets:用版本号 K_CHAT_REV 订阅,
|
||||
* 版本变化时重取一次快照(数组引用每次都是新的,ForEach 的渲染语义
|
||||
* 与拆分前 this.messages = this.messages.slice() 完全一致)。
|
||||
*/
|
||||
|
||||
import { ChatMessage } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST } from '../common/Constants';
|
||||
import { navBar } from '../common/NavBarController';
|
||||
import { ChatAttachment } from '../model/Model';
|
||||
import { chatStore, K_CHAT_REV, K_CHAT_SCROLL_REV, K_CHAT_LOADING, K_CHAT_STAGE } from '../common/ChatStore';
|
||||
import { isChannelMsg, isSelfMsg, structSig } from '../common/ChatFormat';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { ChatBubble } from './ChatBubble';
|
||||
import { PageTopBar } from './PageTopBar';
|
||||
|
||||
@Component
|
||||
export struct ChatStream {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
|
||||
@StorageProp(K_CHAT_STAGE) private stage: string = '';
|
||||
/** 数组快照的订阅信号 */
|
||||
@StorageProp(K_CHAT_REV) @Watch('onChatRev') private rev: number = 0;
|
||||
/** "滚到底"请求信号 */
|
||||
@StorageProp(K_CHAT_SCROLL_REV) @Watch('onScrollReq') private scrollRev: number = 0;
|
||||
@State messages: ChatMessage[] = [];
|
||||
/** 列表底部留白:随输入区展开/加号菜单/待发送附件变化 */
|
||||
@Prop bottomPad: number = 210;
|
||||
onOpenAttachment?: (att: ChatAttachment) => void;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
private autoScrolling: boolean = false;
|
||||
private navHidden: boolean = false;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.messages = chatStore.messages();
|
||||
}
|
||||
|
||||
private onChatRev(): void {
|
||||
this.messages = chatStore.messages();
|
||||
}
|
||||
|
||||
private onScrollReq(): void {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部淡出遮罩的两个端色:背景底色的全不透明 / 全透明版本。
|
||||
* bgPrimary 是 6 位十六进制,这里手拼 8 位 ARGB —— 与 PageTopBar
|
||||
* 顶部淡出用的是同一手法,保证上下两端的融入观感一致。
|
||||
*/
|
||||
private opaqueBottomBg(): string {
|
||||
return '#FF' + this.palette().bgPrimary.substring(1);
|
||||
}
|
||||
|
||||
private transparentBottomBg(): string {
|
||||
return '#00' + this.palette().bgPrimary.substring(1);
|
||||
}
|
||||
|
||||
private scrollToBottom(): void {
|
||||
this.autoScrolling = true;
|
||||
setTimeout(() => {
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
this.autoScrolling = false;
|
||||
this.navHidden = false;
|
||||
navBar.setVisible(true);
|
||||
}, 450);
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动回调:只设置普通标志位,仅在状态翻转时通知 navBar,
|
||||
* 不在回调里做任何耗时操作。navBar.setVisible 内部已去重,
|
||||
* 而布局(padding)不再依赖 navVisible,故翻转只触发 GPU 变换,
|
||||
* 不会引起布局回流——这是滑动流畅的关键。
|
||||
*/
|
||||
private handleScrollDirection(yOffset: number, state: ScrollState): void {
|
||||
if (this.autoScrolling) {
|
||||
return;
|
||||
}
|
||||
// 触顶(近顶部 60vp)且服务端还有更早历史 → 向上懒加载下一页
|
||||
if (yOffset < 60 && chatStore.hasMore()) {
|
||||
chatStore.loadOlder();
|
||||
}
|
||||
if (state === ScrollState.Idle) {
|
||||
if (this.navHidden) {
|
||||
this.navHidden = false;
|
||||
navBar.setVisible(true);
|
||||
}
|
||||
} else {
|
||||
// Scroll / Fling:向下/惯性滚动时隐藏导航与输入栏
|
||||
if (!this.navHidden) {
|
||||
this.navHidden = true;
|
||||
navBar.setVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
// 层1:消息列表(铺满全屏,内容从顶栏遮罩下方穿过时逐渐淡出)
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column() {
|
||||
ForEach(this.messages, (msg: ChatMessage, idx: number) => {
|
||||
ChatBubble({
|
||||
msgId: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
isStreaming: msg.isStreaming === true,
|
||||
isFinal: msg.isFinal === true,
|
||||
reasoningContent: msg.reasoningContent ?? '',
|
||||
reasoningOpen: msg.reasoningOpen === true,
|
||||
toolCalls: msg.toolCalls ?? [],
|
||||
attachment: msg.attachment,
|
||||
source: msg.source ?? '',
|
||||
channel: isChannelMsg(msg.source ?? '', connStore.ensureDeviceId()),
|
||||
mine: isSelfMsg(msg.role, isChannelMsg(msg.source ?? '', connStore.ensureDeviceId())),
|
||||
fresh: chatStore.isFresh(msg.id),
|
||||
onOpenAttachment: (att: ChatAttachment) => {
|
||||
const cb: ((a: ChatAttachment) => void) | undefined = this.onOpenAttachment;
|
||||
if (cb !== undefined) {
|
||||
cb(att);
|
||||
}
|
||||
},
|
||||
onToggleReasoning: (id: number) => {
|
||||
// 在 animateTo 里翻转:展开/收起时 chevron 走已有 .animation,
|
||||
// 面板节点在 animateTo 帧内获得默认过渡,不会再硬切。
|
||||
this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => {
|
||||
chatStore.setReasoningOpen(id, !chatStore.reasoningOpen(id));
|
||||
chatStore.forceRefresh();
|
||||
});
|
||||
},
|
||||
onToggleTool: (id: number, index: number) => {
|
||||
this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => {
|
||||
chatStore.setToolOpen(id, index, !chatStore.toolOpen(id, index));
|
||||
chatStore.forceRefresh();
|
||||
});
|
||||
},
|
||||
})
|
||||
}, (msg: ChatMessage, idx: number) => structSig(msg))
|
||||
|
||||
if (this.loading) {
|
||||
Row({ space: 8 }) {
|
||||
LoadingProgress()
|
||||
.width(16)
|
||||
.height(16)
|
||||
.color(this.palette().accent)
|
||||
Text(this.stage.length > 0 ? this.stage : '处理中...')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Start)
|
||||
.padding({ left: 52, top: 6, bottom: 6 })
|
||||
// if 控制的节点无法用 .animation() 做进出场(那只驱动自身属性的增量更新);
|
||||
// 用 transition 才能让"处理中"这条在出现和消失时都淡入淡出。
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({
|
||||
left: 14,
|
||||
right: 14,
|
||||
top: 76,
|
||||
bottom: this.bottomPad,
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.align(Alignment.Top)
|
||||
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
||||
this.handleScrollDirection(yOffset, state);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
// 层1.5:顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None,触摸完全穿透)
|
||||
PageTopBar({ title: '聊天' })
|
||||
|
||||
// 层1.6:底部淡出遮罩 —— 滚动内容接近悬浮输入区/底部导航时逐渐隐入背景,
|
||||
// 而不是在玻璃后面清晰可见(PageTopBar 顶部淡出手法的镜像,方向相反)。
|
||||
Column()
|
||||
.width('100%')
|
||||
.height(170)
|
||||
.linearGradient({
|
||||
direction: GradientDirection.Bottom,
|
||||
colors: [
|
||||
[this.transparentBottomBg(), 0.0],
|
||||
[this.opaqueBottomBg(), 0.6],
|
||||
[this.opaqueBottomBg(), 1.0],
|
||||
],
|
||||
})
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 气泡内的两张"面板"卡:思考过程卡 + 工具调用卡。
|
||||
*
|
||||
* 从 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 「后端连接」二级页面。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出:连接列表 UI、增删改表单与其状态、
|
||||
* 以及切换连接后必须做的连带动作(刷新 ApiClient / 重启前台桥)
|
||||
* 都属于这一个功能域,收在一个组件里。
|
||||
*
|
||||
* connections / currentId 用 @Link 与一级页面共享:一级页的入口行
|
||||
* 要显示"几个连接配置"和当前连接名,两边必须是同一份数据。
|
||||
*/
|
||||
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { restartForegroundBridge } from '../common/DeviceBridgeSession';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM } from '../common/Constants';
|
||||
import { SubPageLayer, PlainCard } from './SubPage';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
@Component
|
||||
export struct ConnectionsPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Link connections: ConnectionConfig[];
|
||||
@Link currentId: string;
|
||||
onBack?: () => void;
|
||||
onToast?: (msg: string, isError: boolean) => void;
|
||||
@State showAddForm: boolean = false;
|
||||
@State addFormVisible: boolean = false;
|
||||
/**
|
||||
* 表单当前在编辑哪条连接:空串表示新建。
|
||||
*
|
||||
* 之前只有"添加"入口,ConnStore.updateConnection 写好了却没有任何调用者,
|
||||
* 于是地址填错的连接只能删掉重建(API Key 也得重敲)。同一套表单
|
||||
* 靠这个 id 区分保存走 add 还是 update。
|
||||
*/
|
||||
@State editingId: string = '';
|
||||
@State editUrl: string = '';
|
||||
@State editApiKey: string = '';
|
||||
@State editName: string = '';
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private toast(msg: string, isError: boolean): void {
|
||||
const cb: ((m: string, e: boolean) => void) | undefined = this.onToast;
|
||||
if (cb !== undefined) {
|
||||
cb(msg, isError);
|
||||
}
|
||||
}
|
||||
|
||||
private currentConnName(): string {
|
||||
for (let i = 0; i < this.connections.length; i++) {
|
||||
if (this.connections[i].id === this.currentId) {
|
||||
return this.connections[i].name;
|
||||
}
|
||||
}
|
||||
return '未配置';
|
||||
}
|
||||
|
||||
private selectConnection(id: string): void {
|
||||
connStore.setCurrent(id).then(() => {
|
||||
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('已切换连接', false);
|
||||
});
|
||||
}
|
||||
|
||||
private addConnection(): void {
|
||||
const name: string = this.editName.trim();
|
||||
const url: string = this.editUrl.trim();
|
||||
const apiKey: string = this.editApiKey.trim();
|
||||
if (name.length === 0 || url.length === 0) {
|
||||
this.toast('名称和地址不能为空', true);
|
||||
return;
|
||||
}
|
||||
if (this.editingId.length > 0) {
|
||||
this.updateConnection(this.editingId, name, url, apiKey);
|
||||
return;
|
||||
}
|
||||
connStore.addConnection(name, url, apiKey).then(() => {
|
||||
this.closeConnForm();
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已添加', false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存对已有连接的修改。
|
||||
*
|
||||
* 修改当前生效的连接后必须重新 setConnection:ApiClient 持有的是
|
||||
* ConnectionConfig 的引用快照,不刷新的话后续请求还会打到旧地址。
|
||||
*/
|
||||
private updateConnection(id: string, name: string, url: string, apiKey: string): void {
|
||||
connStore.updateConnection(id, name, url, apiKey).then(() => {
|
||||
this.closeConnForm();
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已更新', false);
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开表单:id 为空是新建,非空是编辑并回填原值(API Key 一并带出,避免用户重敲)。 */
|
||||
private openConnForm(conn: ConnectionConfig | null): void {
|
||||
this.showAddForm = true;
|
||||
this.addFormVisible = false;
|
||||
if (conn === null) {
|
||||
this.editingId = '';
|
||||
this.editName = '';
|
||||
this.editUrl = '';
|
||||
this.editApiKey = '';
|
||||
} else {
|
||||
this.editingId = conn.id;
|
||||
this.editName = conn.name;
|
||||
this.editUrl = conn.url;
|
||||
this.editApiKey = conn.apiKey;
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.addFormVisible = true;
|
||||
}, 30);
|
||||
}
|
||||
|
||||
private closeConnForm(): void {
|
||||
this.showAddForm = false;
|
||||
this.addFormVisible = false;
|
||||
this.editingId = '';
|
||||
this.editName = '';
|
||||
this.editUrl = '';
|
||||
this.editApiKey = '';
|
||||
}
|
||||
|
||||
private deleteConnection(id: string): void {
|
||||
connStore.deleteConnection(id).then(() => {
|
||||
this.connections = connStore.getConnections();
|
||||
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
} else {
|
||||
apiClient.clearConnection();
|
||||
}
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已删除', false);
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '后端连接',
|
||||
tab: 3,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
Column() {
|
||||
PlainCard({ caption: '当前连接' }) {
|
||||
Row() {
|
||||
Column({ space: 3 }) {
|
||||
Text(this.currentConnName())
|
||||
.fontSize(15)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(this.currentId.length > 0 ? '已激活,用于所有请求' : '尚未选择连接')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Button('+ 添加')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.onClick(() => {
|
||||
this.openConnForm(null);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
if (this.showAddForm) {
|
||||
Column() {
|
||||
Text(this.editingId.length > 0 ? '编辑连接' : '新建连接')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ bottom: 10 })
|
||||
TextInput({ placeholder: '名称 (如 HomeAgent)', text: this.editName })
|
||||
.height(36).fontSize(13).fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border })
|
||||
.margin({ bottom: 10 })
|
||||
.onChange((v: string) => {
|
||||
this.editName = v;
|
||||
})
|
||||
TextInput({ placeholder: '地址 (域名或 http://192.168.1.100:8080)', text: this.editUrl })
|
||||
.height(36).fontSize(13).fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border })
|
||||
.margin({ bottom: 10 })
|
||||
.onChange((v: string) => {
|
||||
this.editUrl = v;
|
||||
})
|
||||
TextInput({ placeholder: 'API Key (可选)', text: this.editApiKey })
|
||||
.height(36).fontSize(13).fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border })
|
||||
.type(InputType.Password).margin({ bottom: 12 })
|
||||
.onChange((v: string) => {
|
||||
this.editApiKey = v;
|
||||
})
|
||||
|
||||
Row() {
|
||||
Button('取消')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.onClick(() => {
|
||||
this.closeConnForm();
|
||||
})
|
||||
Blank()
|
||||
Button('保存')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.onClick(() => {
|
||||
this.addConnection();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({ width: 1, color: this.palette().kvBorder })
|
||||
.margin({ top: 12 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.opacity(this.addFormVisible ? 1 : 0)
|
||||
.translate({ y: this.addFormVisible ? 0 : 12 })
|
||||
.animation({ duration: 220, curve: Curve.EaseOut })
|
||||
}
|
||||
}
|
||||
|
||||
PlainCard({ caption: '全部连接' }) {
|
||||
if (this.connections.length === 0) {
|
||||
Text('暂无连接。点击上方“添加”配置后端地址。')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
ForEach(this.connections, (conn: ConnectionConfig) => {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(conn.id === this.currentId ? this.palette().accent : '#77809A')
|
||||
.margin({ right: 10 })
|
||||
Column() {
|
||||
Text(conn.name)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(conn.url)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
if (conn.id !== this.currentId) {
|
||||
Button('切换')
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ right: 6 })
|
||||
.onClick(() => {
|
||||
this.selectConnection(conn.id);
|
||||
})
|
||||
} else {
|
||||
Text('使用中')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().accent)
|
||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().accentBg)
|
||||
.margin({ right: 6 })
|
||||
}
|
||||
Button('编辑')
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ right: 6 })
|
||||
.onClick(() => {
|
||||
this.openConnForm(conn);
|
||||
})
|
||||
Button('删除')
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
|
||||
.fontColor('#E84026')
|
||||
.onClick(() => {
|
||||
this.deleteConnection(conn.id);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(10)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({
|
||||
width: { left: 3 },
|
||||
color: conn.id === this.currentId ? this.palette().accent : Color.Transparent,
|
||||
})
|
||||
.margin({ bottom: 6 })
|
||||
// 键里带上 name/url:ForEach 对相同键只更新绑定、不重跑 @Builder 体,
|
||||
// 只用 id 做键时改完地址这一行还显示旧值。行内没有 TextInput,
|
||||
// 因此把可变字段放进键不会有"编辑时焦点被销毁"的副作用。
|
||||
}, (conn: ConnectionConfig) => conn.id + '|' + conn.name + '|' + conn.url)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
316
cmd/ohos/HomeAgent/entry/src/main/ets/components/DevicePanes.ets
Normal file
316
cmd/ohos/HomeAgent/entry/src/main/ets/components/DevicePanes.ets
Normal file
@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 设备页的四个二级页面内容:本机设备 / 设备能力 / 设备通道 / 接入的设备。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 设备页一级入口列表(本机 / 通道两组)。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { fileIo } from '@kit.CoreFileKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
|
||||
/**
|
||||
* 背景层:底色 + 三层径向渐变光斑(右上蓝 / 左中青 / 底部深),
|
||||
* 对应 WebGUI 的 grad-a / grad-b / grad-c。
|
||||
*
|
||||
* 独立成文件的原因:Navigation 的 NavDestination 在栈模式下会整屏盖住
|
||||
* 一级内容,必须自带同款背景(否则透出下层列表形成重影)。
|
||||
* 如果这个组件留在 Index.ets 里,二级页面 import 它会与
|
||||
* Index -> SettingsPage -> Index 形成循环依赖。
|
||||
*/
|
||||
@Component
|
||||
export struct GradientBackground {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp('bgImage') @Watch('onBgChanged') private bgImage: string = '';
|
||||
@StorageProp('bgOpacity') private bgOpacity: number = 0.25;
|
||||
/**
|
||||
* 背景图解码后的位图。
|
||||
*
|
||||
* 为什么不把路径字符串直接交给 Image:
|
||||
* 把沙箱路径交给 Image 要同时满足"带 file:// 协议头"和"带可识别的图片后缀"
|
||||
* 两个隐含前提,任何一条不满足都只是静默不显示 —— 前两轮"背景不生效"就是
|
||||
* 卡在这里,而且没有任何可观测的失败点。
|
||||
* 这里改成自己用 ImageSource 解码(与聊天图片附件同一条已验证通路),
|
||||
* 成功与失败都能落 hilog,Image 只负责画一张现成的 PixelMap。
|
||||
*/
|
||||
@State private bgPixel: image.PixelMap | undefined = undefined;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.decodeBg();
|
||||
}
|
||||
|
||||
private onBgChanged(): void {
|
||||
this.decodeBg();
|
||||
}
|
||||
|
||||
/** 解码沙箱/网络背景图为 PixelMap;失败时清空,只留渐变底。 */
|
||||
private async decodeBg(): Promise<void> {
|
||||
const src: string = this.bgImage;
|
||||
if (src.length === 0) {
|
||||
this.bgPixel = undefined;
|
||||
return;
|
||||
}
|
||||
// 只处理本地沙箱文件;http(s) 背景图不是本应用的场景(设置页只给图库选择)
|
||||
const path: string = src.startsWith('file://') ? src.substring(7) : src;
|
||||
if (!path.startsWith('/')) {
|
||||
hilog.error(0x0000, 'HomeAgent', 'bg: unsupported source');
|
||||
this.bgPixel = undefined;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
|
||||
const srcObj: image.ImageSource = image.createImageSource(f.fd);
|
||||
const pm: image.PixelMap = await srcObj.createPixelMap();
|
||||
await srcObj.release();
|
||||
fileIo.closeSync(f);
|
||||
this.bgPixel = pm;
|
||||
hilog.info(0x0000, 'HomeAgent', 'bg decoded ok');
|
||||
} catch (e) {
|
||||
// 失败必须留痕:否则表现就是"设置了但没生效",无从定位
|
||||
const err = e as BusinessError;
|
||||
hilog.error(0x0000, 'HomeAgent', 'bg decode failed code=%{public}d', err.code as number);
|
||||
this.bgPixel = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
// 底色
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(this.palette().bgPrimary)
|
||||
|
||||
// grad-a: 右上角蓝色光斑
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.radialGradient({
|
||||
center: ['88%', '-4%'],
|
||||
radius: 520,
|
||||
colors: [[this.palette().gradA, 0.0], [this.palette().gradAEnd, 1.0]],
|
||||
})
|
||||
|
||||
// grad-b: 左中部霜冻青光斑
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.radialGradient({
|
||||
center: ['-6%', '38%'],
|
||||
radius: 470,
|
||||
colors: [[this.palette().gradB, 0.0], [this.palette().gradBEnd, 1.0]],
|
||||
})
|
||||
|
||||
// grad-c: 底部深色光斑
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.radialGradient({
|
||||
center: ['50%', '110%'],
|
||||
radius: 560,
|
||||
colors: [[this.palette().gradC, 0.0], [this.palette().gradCEnd, 1.0]],
|
||||
})
|
||||
|
||||
// 自定义背景图(可选):叠在渐变光斑之上、页面内容之下
|
||||
if (this.bgPixel !== undefined) {
|
||||
Image(this.bgPixel)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.objectFit(ImageFit.Cover)
|
||||
.opacity(this.bgOpacity)
|
||||
.draggable(false)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
import { MarkdownStream, StreamingMarkdown } from '@ycj3/streaming-markdown';
|
||||
import type { StreamingMarkdownConfig } from '@ycj3/streaming-markdown';
|
||||
import { StaticMarkdownView } from './StaticMarkdown';
|
||||
import { DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* MarkdownView — unified markdown renderer for chat messages.
|
||||
*
|
||||
* - isStreaming=true → uses @ycj3/streaming-markdown live StreamingMarkdown
|
||||
* (incremental, animated — ideal for SSE output).
|
||||
* - isStreaming=false → uses StaticMarkdownView (parses once, no timers,
|
||||
* instant render — ideal for history / finalized messages).
|
||||
*/
|
||||
@Component
|
||||
export struct MarkdownView {
|
||||
@Prop content: string = '';
|
||||
@Prop isStreaming: boolean = false;
|
||||
@Prop isDark: boolean = true;
|
||||
|
||||
private stream: MarkdownStream = new MarkdownStream({ mode: 'word', interval: 18 });
|
||||
private seeded: boolean = false;
|
||||
private pushedLen: number = 0;
|
||||
|
||||
aboutToAppear(): void {
|
||||
if (this.isStreaming) {
|
||||
this.seedStream();
|
||||
this.seeded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Triggered by @Prop changes (streaming updates)
|
||||
private onContentChange(): void {
|
||||
if (this.isStreaming && this.seeded) {
|
||||
const c: string = this.content;
|
||||
const prev: number = this.pushedLen;
|
||||
if (c.length > prev) {
|
||||
const delta: string = c.substring(prev);
|
||||
this.stream.append(delta);
|
||||
this.pushedLen = c.length;
|
||||
return;
|
||||
}
|
||||
// non-prefix change → reseed
|
||||
this.seedStream();
|
||||
}
|
||||
}
|
||||
|
||||
private seedStream(): void {
|
||||
this.stream.reset();
|
||||
this.pushedLen = 0;
|
||||
const c: string = this.content;
|
||||
if (c.length > 0) {
|
||||
this.stream.append(c);
|
||||
this.pushedLen = c.length;
|
||||
}
|
||||
// Streaming message stays open; finish happens when isStreaming flips false.
|
||||
}
|
||||
|
||||
/**
|
||||
* When a streaming message finalizes, mark the stream complete.
|
||||
*/
|
||||
private onStreamingEnd(): void {
|
||||
if (this.seeded) {
|
||||
this.stream.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private getConfig(): StreamingMarkdownConfig {
|
||||
const p = this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
const config: StreamingMarkdownConfig = {
|
||||
heading: {
|
||||
sizes: [28, 24, 20, 18, 16, 14],
|
||||
color: p.textPrimary,
|
||||
topSpacing: 8,
|
||||
bottomSpacing: 6,
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
color: p.msgBubbleText,
|
||||
bottomSpacing: 6,
|
||||
},
|
||||
list: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
itemBottomSpacing: 3,
|
||||
color: p.msgBubbleText,
|
||||
},
|
||||
blockquote: {
|
||||
textColor: p.textTertiary,
|
||||
bgColor: p.bgHover,
|
||||
borderColor: p.kvBorder,
|
||||
bottomSpacing: 6,
|
||||
},
|
||||
codeBlock: {
|
||||
borderColor: p.kvBorder,
|
||||
radius: 10,
|
||||
topSpacing: 8,
|
||||
bottomSpacing: 8,
|
||||
},
|
||||
table: {
|
||||
headerBgColor: p.bgHover,
|
||||
borderColor: p.kvBorder,
|
||||
stripeBgColor: p.bgHover,
|
||||
cellFontSize: 13,
|
||||
topSpacing: 6,
|
||||
bottomSpacing: 6,
|
||||
},
|
||||
horizontalRule: {
|
||||
color: p.kvBorder,
|
||||
topSpacing: 8,
|
||||
bottomSpacing: 8,
|
||||
},
|
||||
inline: {
|
||||
linkColor: p.accent,
|
||||
codeTextColor: p.preText,
|
||||
codeBgColor: p.preBg,
|
||||
mathTextColor: p.accent,
|
||||
mathBgColor: p.bgHover,
|
||||
monoFontFamily: 'monospace',
|
||||
},
|
||||
layout: {
|
||||
contentPadding: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
},
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
build() {
|
||||
// 布局说明(已由 uitest dumpLayout 实测确认):
|
||||
// 父气泡 BubbleBody 为内容自适应宽度(constraintSize maxWidth 78%)且带 12vp 左右 padding。
|
||||
// 在这种"内容自适应 + padding"的父节点下,后代节点的 width('100%') 会被解析成父气泡的
|
||||
// 外框宽度而不是内容框宽度,于是 markdown 内容整体右溢出 12vp 并被气泡 clip 裁掉。
|
||||
// 解决办法:用 Row + layoutWeight(1) 代替百分比宽度。layoutWeight 走的是"剩余约束分配"
|
||||
// 而不是百分比解析,能拿到正确的内容框宽度,再把这个确定宽度传给 StaticMarkdown,
|
||||
// 其内部各层的 width('100%') 就有了正确的解析基准。
|
||||
Row() {
|
||||
Column() {
|
||||
if (this.isStreaming) {
|
||||
StreamingMarkdown({
|
||||
stream: this.stream,
|
||||
config: this.getConfig(),
|
||||
})
|
||||
} else {
|
||||
StaticMarkdownView({
|
||||
content: this.content,
|
||||
isDark: this.isDark,
|
||||
})
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* MotionBase —— 全局通用动效的"父组件"。
|
||||
*
|
||||
* 目标:把各个组件重复实现的按压反馈收拢到这唯一一个父组件里,
|
||||
* 全局统一手感(时长 token + EaseOut 曲线 + 形变幅度)。
|
||||
* 公共组件(NavRow / StatusSummaryCard / AttachmentCard / 插件卡片 …)
|
||||
* 在自己的 build 根节点接入 MotionBase,通用按压动效即随父组件生效,
|
||||
* 子组件无需再各自维护 @State pressed + scale + animation + onTouch 四件套。
|
||||
*
|
||||
* 特有动画(多态扩展):留在各组件内部。例如聊天输入框向上弹出
|
||||
* (ChatPage 的 inputMultiLine 上移 + Curve.Friction 展开)、加号菜单浮起、
|
||||
* 折叠面板展开、toast 进出场——这些是"某个组件专属"的动效,
|
||||
* 不应塞进父组件,由各组件按需声明。
|
||||
*
|
||||
* ArkUI V1 的 @Component struct 不能继承另一位 struct 的 build,
|
||||
* 这里用「组合」表达继承:调用组件把内容经唯一内容插槽传给 MotionBase,
|
||||
* MotionBase 在包装节点上统一挂通用动效修饰器。
|
||||
*
|
||||
* 通用动效契约:
|
||||
* - 按压反馈:pressEnabled 时整容器 scale → pressScale,快速回弹。
|
||||
* V1 自带按压态在玻璃/透明底上几乎不可见,这里统一补足;
|
||||
* scale 只作用于容器自身,不侵入子节点外观。
|
||||
* - onPress 只是按压瞬时的轻量钩子(如提前高亮),
|
||||
* 真正的导航/提交语义仍由调用组件的 onClick(点击释放)负责,
|
||||
* 二者不耦合,避免"按下即触发"的手感偏差。
|
||||
*
|
||||
* 用法:
|
||||
* MotionBase({ pressEnabled: true }) {
|
||||
* // 实际内容;外层 onClick 由调用组件自己绑在内容上
|
||||
* }
|
||||
*
|
||||
* 注意:包装节点默认 width('100%')。像聊天气泡里的附件卡那样需要
|
||||
* 「内容自适应宽度」的组件传 fillWidth: false,否则会被撑满整行。
|
||||
*/
|
||||
import { ANIM_FAST, PRESS_SCALE } from '../common/Constants';
|
||||
|
||||
@Component
|
||||
export struct MotionBase {
|
||||
/** 内容插槽:调用组件把实际内容传进来 */
|
||||
@BuilderParam content: () => void;
|
||||
/** 是否启用按压反馈(默认关,纯展示容器传 false 零开销) */
|
||||
@Prop pressEnabled: boolean = false;
|
||||
/** 按下的瞬间回调(可选):只做轻量即时反馈,不要在这里做导航/提交 */
|
||||
onPress?: () => void;
|
||||
/** 按压缩放幅度,默认全局 PRESS_SCALE */
|
||||
@Prop pressScale: number = PRESS_SCALE;
|
||||
/** 按压时整体透明度(1 = 不启用透明度反馈) */
|
||||
@Prop pressOpacity: number = 1;
|
||||
/** 包装节点是否撑满父宽;false = 由内容决定宽度(气泡内的卡片必须传 false) */
|
||||
@Prop fillWidth: boolean = true;
|
||||
/** 在 Row/Column 中参与剩余空间分配的权重;0 = 不参与(等分排列的按钮传 1) */
|
||||
@Prop flexWeight: number = 0;
|
||||
|
||||
/** 按压态:只对容器自身 scale 生效 */
|
||||
@State private pressed: boolean = false;
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
this.content()
|
||||
}
|
||||
.width(this.fillWidth && this.flexWeight <= 0 ? '100%' : undefined)
|
||||
.layoutWeight(this.flexWeight)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
// ===== 通用动效(父组件统一声明,子组件继承) =====
|
||||
// 按压回弹:pressed 驱动 scale 增量,.animation 在下方覆盖该增量属性
|
||||
.scale({
|
||||
x: this.pressed ? this.pressScale : 1,
|
||||
y: this.pressed ? this.pressScale : 1,
|
||||
})
|
||||
.opacity(this.pressed ? this.pressOpacity : 1)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.onTouch((e: TouchEvent) => {
|
||||
if (!this.pressEnabled) {
|
||||
return;
|
||||
}
|
||||
if (e.type === TouchType.Down) {
|
||||
this.pressed = true;
|
||||
const cb = this.onPress;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
} else if (e.type === TouchType.Up || e.type === TouchType.Cancel) {
|
||||
this.pressed = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
215
cmd/ohos/HomeAgent/entry/src/main/ets/components/PageTopBar.ets
Normal file
215
cmd/ohos/HomeAgent/entry/src/main/ets/components/PageTopBar.ets
Normal file
@ -0,0 +1,215 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { ANIM_FAST, ANIM_SLOW } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
|
||||
/**
|
||||
* 顶栏高度(vp)。页面 padding-top 应略大于此值,避免内容被标题压住,
|
||||
* 同时不要留过多空白。
|
||||
*/
|
||||
export const TOP_BAR_HEIGHT: number = 68;
|
||||
|
||||
/**
|
||||
* 页面顶栏遮罩:仅标题文字,无按钮无状态。
|
||||
* 浮在滚动区之上(页面用 PageTopBarLayer 置顶),背景为 不透明 -> 透明 的线性渐变,
|
||||
* 滚动内容从其下方穿过时逐渐淡出,形成"逐渐加深"的柔化边界,而不是硬截断。
|
||||
*
|
||||
* 关键:自定义组件被外部施加 .position() 时,ArkUI 会生成一个 __Common__ 包裹节点,
|
||||
* 该节点默认铺满父约束并使用默认命中测试 —— 会吞掉整页触摸事件。
|
||||
* 因此必须在【调用点】同时显式给出尺寸与 hitTestBehavior(None),
|
||||
* 统一封装在 PageTopBarLayer 里,页面不要再手写 .position()。
|
||||
*/
|
||||
@Component
|
||||
export struct PageTopBar {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop title: string = '';
|
||||
|
||||
build() {
|
||||
// 外层撑满整页并顶部对齐,替代调用点的 .position():
|
||||
// 自定义组件一旦在调用点被施加 .position(),ArkUI 会生成铺满父约束的
|
||||
// __Common__ 包裹节点,该节点使用默认命中测试,会吞掉整页的滚动与点击。
|
||||
// 这里改为自身撑满 + 全链路 HitTestMode.None,触摸完全穿透到下层滚动区。
|
||||
Column() {
|
||||
Column() {
|
||||
Text(this.title)
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.margin({ left: 16, top: 10 })
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
.width('100%')
|
||||
.height(TOP_BAR_HEIGHT)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.linearGradient({
|
||||
direction: GradientDirection.Bottom,
|
||||
colors: [
|
||||
[this.opaqueBg(), 0.0],
|
||||
[this.opaqueBg(), 0.45],
|
||||
[this.transparentBg(), 1.0],
|
||||
],
|
||||
})
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Start)
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private opaqueBg(): string {
|
||||
const bg: string = this.palette().bgPrimary;
|
||||
return '#FF' + bg.substring(1);
|
||||
}
|
||||
|
||||
private transparentBg(): string {
|
||||
const bg: string = this.palette().bgPrimary;
|
||||
return '#00' + bg.substring(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立气态玻璃节点:单个悬浮组件,与底部导航同款玻璃(半透明底 + 高光渐变)。
|
||||
* 尺寸自适应内容(宽度随内容,高度/圆角参数化),视觉上独立、不与其他组件共框。
|
||||
* 用于承载状态胶囊、图标按钮等单个悬浮元素。
|
||||
*/
|
||||
@Component
|
||||
export struct GlassShell {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop nodeHeight: number = 32;
|
||||
@Prop nodeRadius: number = 16;
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
this.content()
|
||||
}
|
||||
.height(this.nodeHeight)
|
||||
.padding({ left: 8, right: 8 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(this.nodeRadius)
|
||||
.border({
|
||||
width: { left: 1, top: 1, right: 1, bottom: 1 },
|
||||
color: this.palette().navBarBorder,
|
||||
})
|
||||
.shadow({
|
||||
radius: 16,
|
||||
color: this.palette().shadow,
|
||||
offsetY: 5,
|
||||
})
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬浮区圆形图标按钮的直径(vp)。徽标不使用它——徽标是竖向窄条。
|
||||
*/
|
||||
export const FLOAT_NODE_SIZE: number = 42;
|
||||
|
||||
/**
|
||||
* 悬浮图标按钮:与底部导航同一套玻璃语言的圆形按钮。
|
||||
* 统一 42vp 直径、同款玻璃底/描边/投影,图标 18vp。
|
||||
*/
|
||||
@Component
|
||||
export struct FloatIconButton {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop icon: Resource | undefined = undefined;
|
||||
@Prop accent: boolean = false;
|
||||
onTap?: () => void;
|
||||
|
||||
build() {
|
||||
// 按压缩放交给 MotionBase(父组件统一手感);
|
||||
// fillWidth: false —— 悬浮区靠右对齐,包装节点必须按内容 42vp 收窄。
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button() {
|
||||
Image(this.icon)
|
||||
.width(18)
|
||||
.height(18)
|
||||
.fillColor(this.accent ? this.palette().accent : this.palette().textSecondary)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
}
|
||||
.width(FLOAT_NODE_SIZE)
|
||||
.height(FLOAT_NODE_SIZE)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.border({
|
||||
width: { left: 1, top: 1, right: 1, bottom: 1 },
|
||||
color: this.palette().navBarBorder,
|
||||
})
|
||||
.shadow({
|
||||
radius: 24,
|
||||
color: this.palette().shadow,
|
||||
offsetY: 8,
|
||||
})
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬浮区的"一行":宽度自适应内容,内部组件从右向左排列(右边缘与导航栏平齐)。
|
||||
* 放在 NavFloatOverlay 内部使用;声明顺序越靠前越贴近导航栏,后声明的行叠在其上方。
|
||||
*/
|
||||
@Component
|
||||
export struct NavFloatRow {
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Row({ space: 8 }) {
|
||||
this.content()
|
||||
}
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部悬浮容器:透明、无任何背景/边框(视觉上不包裹任何东西,组件彼此独立),
|
||||
* 仅负责 右侧对齐(与导航栏同宽 88%)、自下而上分行堆叠 与 随滚动渐隐/滑出动画。
|
||||
*
|
||||
* 布局约定:直接子节点是"一行"(通常是自适应宽度的 Row)。
|
||||
* 行按【从上到下】声明:先声明的行在上方,最后声明的行贴住导航栏,
|
||||
* 即"一行放不下时向上面再开一行"(把新行写在前面)。
|
||||
*
|
||||
* 关键:这里必须用 Column 而不是 Flex —— Flex 在 ArkUI 中默认铺满父约束,
|
||||
* 会形成一个覆盖整页的默认命中测试节点,吞掉页面滚动与点击;
|
||||
* Column 高度自适应内容。再加 HitTestMode.Transparent,
|
||||
* 让每行右侧之外的空白区域触摸穿透到下层滚动区。
|
||||
*/
|
||||
@Component
|
||||
export struct NavFloatOverlay {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp('navVisible') private navVisible: boolean = true;
|
||||
@StorageProp('currentTab') private currentTab: number = 0;
|
||||
@Prop tab: number = 0;
|
||||
@Prop alignEnd: boolean = true;
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Column({ space: 8 }) {
|
||||
this.content()
|
||||
}
|
||||
.width('88%')
|
||||
.alignItems(this.alignEnd ? HorizontalAlign.End : HorizontalAlign.Start)
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
.margin({ bottom: 94 })
|
||||
.translate({ y: (!this.navVisible || this.currentTab !== this.tab) ? 140 : 0 })
|
||||
.opacity((!this.navVisible || this.currentTab !== this.tab) ? 0 : 1)
|
||||
.animation({ duration: ANIM_SLOW, curve: Curve.EaseOut })
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 插件详情(二级页面内容)。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 插件一级列表(列表卡 + 三种占位态)。
|
||||
*
|
||||
* 从 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%')
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 安装表单悬浮卡。
|
||||
*
|
||||
* 从 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 }))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,385 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../common/Constants';
|
||||
import { GradientBackground } from './GradientBackground';
|
||||
import { PageTopBar } from './PageTopBar';
|
||||
import { MotionBase } from './MotionBase';
|
||||
|
||||
/**
|
||||
* agent 主动推送的前台内容页。
|
||||
*
|
||||
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
|
||||
* 从而让左侧一级页面和主导航保持可见、可操作。
|
||||
*/
|
||||
@Component
|
||||
export struct ScreensuePage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop pushedText: string = '';
|
||||
@Prop countdown: number = 0;
|
||||
onClose: () => void = () => {
|
||||
};
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
GradientBackground()
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 14 }) {
|
||||
Row({ space: 8 }) {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(this.palette().accent)
|
||||
Text('agent 推送')
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
Blank()
|
||||
if (this.countdown > 0) {
|
||||
Text(this.countdown.toString() + 's')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
} else {
|
||||
Text('常驻')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Column() {
|
||||
Text(this.pushedText)
|
||||
.fontSize(16)
|
||||
.lineHeight(25)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.copyOption(CopyOptions.LocalDevice)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.borderRadius(18)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 18, right: 18, top: 82, bottom: 96 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
.align(Alignment.Top)
|
||||
|
||||
PageTopBar({ title: '推送内容' })
|
||||
|
||||
Row() {
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button('关闭')
|
||||
.height(42)
|
||||
.padding({ left: 22, right: 22 })
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(Color.White)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.borderRadius(21)
|
||||
.onClick(() => {
|
||||
this.onClose();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 18, right: 18, bottom: 22 })
|
||||
.justifyContent(FlexAlign.End)
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 18 }))
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(this.palette().bgPrimary)
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,389 @@
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage, noConnectionMessage } from '../common/UserError';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { RADIUS_SM } from '../common/Constants';
|
||||
import { COLOR_ERROR } from '../common/Constants';
|
||||
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* 可复用的后端配置编辑器(按 key 前缀取一段配置并就地编辑)。
|
||||
*
|
||||
* 抽出来的原因:配置项不应该全部堆在「设置 → 后端配置」一个平铺列表里。
|
||||
* - core.* 各分类归到「核心」下一级;
|
||||
* - plugin.<name>.* 属于插件本身,放进该插件的详情页 —— 就是这个组件的用处。
|
||||
*
|
||||
* 后端接口:
|
||||
* - GET /settings?prefix=<prefix> → { settings: {k:v}, meta: {k:{...}} }
|
||||
* - PUT /settings → { key, value }(value 一律字符串)
|
||||
*/
|
||||
|
||||
interface SettingMetaRaw {
|
||||
key: string;
|
||||
type: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
interface EditorEntry {
|
||||
key: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
type: string;
|
||||
options: string[];
|
||||
value: string;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
interface SaveBody {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct SettingsEditor {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** key 前缀,例如 'plugin.ai_image.' 或 'core.llm.' */
|
||||
@Prop @Watch('onPrefixChanged') prefix: string = '';
|
||||
/** 空列表时的提示语 */
|
||||
@Prop emptyHint: string = '暂无可配置项';
|
||||
@State private entries: EditorEntry[] = [];
|
||||
@State private loading: boolean = false;
|
||||
@State private err: string = '';
|
||||
@State private toast: string = '';
|
||||
@State private toastErr: boolean = false;
|
||||
private original: Record<string, string> = {};
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
/** 宽屏下同一个组件实例会被复用(切换插件只改 prefix),必须重新拉取 */
|
||||
private onPrefixChanged(): void {
|
||||
this.entries = [];
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用前缀。
|
||||
*
|
||||
* 后端 GET /settings?prefix= 对 plugin.* 走的是另一条分支:
|
||||
* 它把 prefix 当作 "plugin." + 插件名 来切表,再自己拼 prefix + "." + key。
|
||||
* 所以传 'plugin.browser.'(带尾点)会被解析成插件名 "browser.",查不到表,
|
||||
* settings 返回空 —— 这就是插件详情页配置卡片空白的原因。
|
||||
* 查询必须去掉尾点,返回的 key 仍是 'plugin.browser.timeout' 这种全名,
|
||||
* 所以本地过滤/短标签依旧用带尾点的 this.prefix。
|
||||
*/
|
||||
private queryPrefix(): string {
|
||||
const p: string = this.prefix;
|
||||
return p.endsWith('.') ? p.substring(0, p.length - 1) : p;
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
if (!apiClient.hasConnection()) {
|
||||
this.err = noConnectionMessage();
|
||||
return;
|
||||
}
|
||||
if (this.prefix.length === 0) {
|
||||
this.entries = [];
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.err = '';
|
||||
try {
|
||||
const resp = await apiClient.getWithTimeout('/settings?prefix=' + this.queryPrefix(), 15000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
|
||||
const metaStore: Record<string, SettingMetaRaw> = {};
|
||||
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 opts: string[] = [];
|
||||
const optsRaw: Object | undefined = item['options'];
|
||||
if (optsRaw !== undefined && optsRaw !== null) {
|
||||
const oa: Object[] = optsRaw as Object[];
|
||||
for (let i = 0; i < oa.length; i++) {
|
||||
const s: string = oa[i] as string ?? '';
|
||||
if (s.length > 0) {
|
||||
opts.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
const entryMeta: 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,
|
||||
};
|
||||
// 两个索引都建:
|
||||
// - mk 是 map 的键,插件分支里后端会把它拼成 'plugin.<name>.' + 完整 key,
|
||||
// 于是变成 'plugin.ai_image.plugin.ai_image.api_key' 这种双前缀,对不上;
|
||||
// - entryMeta.key 是定义自带的真实全名('plugin.ai_image.api_key'),才是能匹配的那个。
|
||||
// 只用 mk 索引就会让插件配置全部退化成"无显示名、类型按 string"。
|
||||
metaStore[mk] = entryMeta;
|
||||
if (entryMeta.key.length > 0) {
|
||||
metaStore[entryMeta.key] = entryMeta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const list: EditorEntry[] = [];
|
||||
this.original = {};
|
||||
const rawVals: Object | undefined = obj['settings'];
|
||||
if (rawVals !== undefined && rawVals !== null) {
|
||||
const vObj: Record<string, Object> = rawVals as Record<string, Object>;
|
||||
const keys: string[] = Object.keys(vObj).filter((k: string): boolean => {
|
||||
return k.startsWith(this.prefix);
|
||||
});
|
||||
keys.sort((a: string, b: string): number => a.localeCompare(b));
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k: string = keys[i];
|
||||
const raw: Object = vObj[k];
|
||||
let sv: string;
|
||||
if (typeof raw === 'string') {
|
||||
sv = raw as string;
|
||||
} else if (typeof raw === 'boolean' || typeof raw === 'number') {
|
||||
sv = String(raw);
|
||||
} else {
|
||||
sv = JSON.stringify(raw);
|
||||
}
|
||||
this.original[k] = sv;
|
||||
const meta: SettingMetaRaw | undefined = metaStore[k];
|
||||
list.push({
|
||||
key: k,
|
||||
displayName: meta !== undefined && meta.displayName.length > 0
|
||||
? meta.displayName : shortLabel(k, this.prefix),
|
||||
description: meta !== undefined ? meta.description : '',
|
||||
type: meta !== undefined ? meta.type : 'string',
|
||||
options: meta !== undefined ? meta.options : [],
|
||||
value: sv,
|
||||
dirty: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.entries = list;
|
||||
} catch (e) {
|
||||
this.err = userMessage('settings.load', e);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private markEntry(key: string, value: string, dirty: boolean): void {
|
||||
const next: EditorEntry[] = [];
|
||||
for (let i = 0; i < this.entries.length; i++) {
|
||||
const e: EditorEntry = this.entries[i];
|
||||
if (e.key === key) {
|
||||
next.push({
|
||||
key: e.key,
|
||||
displayName: e.displayName,
|
||||
description: e.description,
|
||||
type: e.type,
|
||||
options: e.options,
|
||||
value: value,
|
||||
dirty: dirty,
|
||||
});
|
||||
} else {
|
||||
next.push(e);
|
||||
}
|
||||
}
|
||||
this.entries = next;
|
||||
}
|
||||
|
||||
private onEdit(key: string, value: string): void {
|
||||
const orig: string = this.original[key] ?? '';
|
||||
this.markEntry(key, value, value !== orig);
|
||||
}
|
||||
|
||||
private latestValue(key: string): string {
|
||||
for (let i = 0; i < this.entries.length; i++) {
|
||||
if (this.entries[i].key === key) {
|
||||
return this.entries[i].value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private async save(entry: EditorEntry, newValue: string): Promise<void> {
|
||||
let payload: string = newValue.trim();
|
||||
if (entry.type === 'bool') {
|
||||
payload = newValue === 'true' ? 'true' : 'false';
|
||||
}
|
||||
try {
|
||||
const body: SaveBody = { key: entry.key, value: payload };
|
||||
await apiClient.request('/settings', 'PUT', JSON.stringify(body), 10000);
|
||||
this.original[entry.key] = payload;
|
||||
this.markEntry(entry.key, payload, false);
|
||||
this.showToast('已保存', false);
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('settings.save', e), true);
|
||||
}
|
||||
}
|
||||
|
||||
private showToast(msg: string, isErr: boolean): void {
|
||||
this.toast = msg;
|
||||
this.toastErr = isErr;
|
||||
setTimeout(() => {
|
||||
this.toast = '';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.loading && this.entries.length === 0) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(22)
|
||||
.height(22)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 14, bottom: 14 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (this.err.length > 0) {
|
||||
Text(this.err)
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.width('100%')
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (!this.loading && this.err.length === 0 && this.entries.length === 0) {
|
||||
Text(this.emptyHint)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.width('100%')
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
ForEach(this.entries, (entry: EditorEntry) => {
|
||||
this.EntryRow(entry)
|
||||
}, (entry: EditorEntry) => entry.key + '|' + entry.value + '|' + (entry.dirty ? 'd' : 'c'))
|
||||
|
||||
if (this.toast.length > 0) {
|
||||
Text(this.toast)
|
||||
.fontSize(11)
|
||||
.fontColor(this.toastErr ? COLOR_ERROR : this.palette().accent)
|
||||
.margin({ top: 4 })
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
EntryRow(entry: EditorEntry) {
|
||||
Column() {
|
||||
Text(entry.displayName)
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(entry.key)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (entry.description.length > 0) {
|
||||
Text(entry.description)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(3)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ top: 3 })
|
||||
}
|
||||
|
||||
if (entry.type === 'bool') {
|
||||
Row() {
|
||||
Text(entry.value === 'true' ? '已开启' : '已关闭')
|
||||
.fontSize(12)
|
||||
.fontColor(entry.value === 'true' ? this.palette().accent : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
Blank()
|
||||
Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' })
|
||||
.selectedColor(this.palette().accent)
|
||||
.onChange((on: boolean) => {
|
||||
this.save(entry, on ? 'true' : 'false');
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 6 })
|
||||
} else if (entry.type === 'select' && entry.options.length > 0) {
|
||||
Flex({
|
||||
direction: FlexDirection.Row,
|
||||
justifyContent: FlexAlign.Start,
|
||||
alignItems: ItemAlign.Center,
|
||||
wrap: FlexWrap.Wrap,
|
||||
}) {
|
||||
ForEach(entry.options, (opt: string) => {
|
||||
Button(opt)
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.margin({ right: 6, bottom: 6 })
|
||||
.backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover)
|
||||
.fontColor(entry.value === opt ? Color.White : this.palette().textSecondary)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
this.save(entry, opt);
|
||||
})
|
||||
}, (opt: string) => opt)
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 6 })
|
||||
} else {
|
||||
Row() {
|
||||
TextInput({ text: 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(entry.type === 'password' ? InputType.Password : InputType.Normal)
|
||||
.layoutWeight(1)
|
||||
.onChange((v: string) => {
|
||||
this.onEdit(entry.key, v);
|
||||
})
|
||||
Button('保存')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => {
|
||||
this.save(entry, this.latestValue(entry.key));
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 6 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 10, bottom: 10 })
|
||||
.border({ width: { bottom: 1 }, color: this.palette().kvBorder })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/** 去掉前缀后的短标签:plugin.ai_image.model → model */
|
||||
function shortLabel(key: string, prefix: string): string {
|
||||
const s: string = key.startsWith(prefix) ? key.substring(prefix.length) : key;
|
||||
return s.length > 0 ? s : key;
|
||||
}
|
||||
@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 单个配置项卡片。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 设置一级页的页面骨架:滚动区 + 顶栏 + 悬浮区 + 轻提示。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 设置一级页的入口列表。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Static Markdown → ArkUI renderer for COMPLETE (non-streaming) chat messages.
|
||||
*
|
||||
* Parses once in aboutToAppear, builds a component tree with no timers — instant rendering.
|
||||
* Uses Span children inside Text for inline bold/italic/code/link formatting.
|
||||
*
|
||||
* 解析规则在 common/MarkdownParser.ets(已抽出,见那里);
|
||||
* 本文件只负责把 MdBlock 渲染成 ArkUI 组件树。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { RADIUS_SM } from '../common/Constants';
|
||||
import { MdBlock, MdSpan, parseBlocks, parseInline } from '../common/MarkdownParser';
|
||||
|
||||
@Component
|
||||
export struct StaticMarkdownView {
|
||||
@Prop content: string = '';
|
||||
@Prop isDark: boolean = true;
|
||||
private blocks: MdBlock[] = [];
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.blocks = parseBlocks(this.content);
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private headingSize(level: number): number {
|
||||
if (level === 1) {
|
||||
return 22;
|
||||
}
|
||||
if (level === 2) {
|
||||
return 19;
|
||||
}
|
||||
if (level === 3) {
|
||||
return 17;
|
||||
}
|
||||
if (level === 4) {
|
||||
return 15.5;
|
||||
}
|
||||
return 14;
|
||||
}
|
||||
|
||||
// ── Block builders ─────────────────────────────────────────────────────────
|
||||
|
||||
@Builder
|
||||
ParaBlock(text: string) {
|
||||
Column({ space: 1 }) {
|
||||
ForEach(this.splitNewlines(text), (ln: string, idx: number) => {
|
||||
// Render inline spans inside this line
|
||||
Text() {
|
||||
ForEach(parseInline(ln), (sp: MdSpan, si: number) => {
|
||||
Span(sp.text)
|
||||
.fontSize(15)
|
||||
.fontColor(sp.link === true ? this.palette().accent : sp.code === true ? this.palette().preText : this.palette().msgBubbleText)
|
||||
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
|
||||
.fontFamily(sp.code === true ? 'monospace' : '-')
|
||||
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
|
||||
.borderRadius(3)
|
||||
.padding(sp.code === true ? { left: 3, right: 3, top: 1, bottom: 1 } : {})
|
||||
.decoration(sp.link === true ? { type: TextDecorationType.Underline } : undefined)
|
||||
}, (sp: MdSpan, si: number) => idx.toString() + '_' + si.toString())
|
||||
}
|
||||
.fontSize(15)
|
||||
.lineHeight(24)
|
||||
.fontColor(this.palette().msgBubbleText)
|
||||
.width('100%')
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.textAlign(TextAlign.Start)
|
||||
}, (ln: string, idx: number) => 'p' + idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 2, bottom: 4 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
HeadingBlock(block: MdBlock) {
|
||||
Text(block.text ?? '')
|
||||
.fontSize(this.headingSize(block.level ?? 1))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.lineHeight(this.headingSize(block.level ?? 1) + 8)
|
||||
.width('100%')
|
||||
.margin({ top: 6, bottom: 4 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
CodeBlock(block: MdBlock) {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(block.lang !== undefined && block.lang.length > 0 ? block.lang : 'code')
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.fontFamily('monospace')
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
|
||||
// Code body
|
||||
Column() {
|
||||
ForEach(block.codeLines ?? [], (ln: string, idx: number) => {
|
||||
Text(ln.length > 0 ? ln : ' ')
|
||||
.fontSize(12.5)
|
||||
.lineHeight(19)
|
||||
.fontFamily('monospace')
|
||||
.fontColor(this.palette().preText)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
}, (ln: string, idx: number) => 'c' + idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
}
|
||||
.width('100%')
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().preBg)
|
||||
.border({ width: 1, color: this.palette().kvBorder })
|
||||
.clip(true)
|
||||
.margin({ top: 4, bottom: 6 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
ListBlock(block: MdBlock) {
|
||||
Column({ space: 2 }) {
|
||||
ForEach(block.items ?? [], (item: string, idx: number) => {
|
||||
Row({ space: 6 }) {
|
||||
Text('•')
|
||||
.fontSize(15)
|
||||
.fontColor(this.palette().accent)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.margin({ top: 1 })
|
||||
Text() {
|
||||
ForEach(parseInline(item), (sp: MdSpan, si: number) => {
|
||||
Span(sp.text)
|
||||
.fontSize(15)
|
||||
.fontColor(sp.code === true ? this.palette().preText : this.palette().msgBubbleText)
|
||||
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
|
||||
.fontFamily(sp.code === true ? 'monospace' : '-')
|
||||
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
|
||||
.borderRadius(3)
|
||||
.padding(sp.code === true ? { left: 3, right: 3, top: 1, bottom: 1 } : {})
|
||||
}, (sp: MdSpan, si: number) => 'li' + idx.toString() + '_' + si.toString())
|
||||
}
|
||||
.fontSize(15)
|
||||
.lineHeight(23)
|
||||
.fontColor(this.palette().msgBubbleText)
|
||||
.layoutWeight(1)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}, (item: string, idx: number) => idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 2, bottom: 4 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
OlBlock(block: MdBlock) {
|
||||
Column({ space: 2 }) {
|
||||
ForEach(block.items ?? [], (item: string, idx: number) => {
|
||||
Row({ space: 6 }) {
|
||||
Text((idx + 1).toString() + '.')
|
||||
.fontSize(15)
|
||||
.fontColor(this.palette().accent)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.margin({ top: 1 })
|
||||
Text() {
|
||||
ForEach(parseInline(item), (sp: MdSpan, si: number) => {
|
||||
Span(sp.text)
|
||||
.fontSize(15)
|
||||
.fontColor(sp.code === true ? this.palette().preText : this.palette().msgBubbleText)
|
||||
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
|
||||
.fontFamily(sp.code === true ? 'monospace' : '-')
|
||||
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
|
||||
.borderRadius(3)
|
||||
.padding(sp.code === true ? { left: 3, right: 3, top: 1, bottom: 1 } : {})
|
||||
}, (sp: MdSpan, si: number) => 'oli' + idx.toString() + '_' + si.toString())
|
||||
}
|
||||
.fontSize(15)
|
||||
.lineHeight(23)
|
||||
.fontColor(this.palette().msgBubbleText)
|
||||
.layoutWeight(1)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}, (item: string, idx: number) => idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 2, bottom: 4 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
BlockquoteBlock(text: string) {
|
||||
Column() {
|
||||
ForEach(this.splitNewlines(text), (ln: string, idx: number) => {
|
||||
Text() {
|
||||
ForEach(parseInline(ln), (sp: MdSpan, si: number) => {
|
||||
Span(sp.text)
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textTertiary)
|
||||
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
|
||||
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
|
||||
}, (sp: MdSpan, si: number) => 'bq' + idx.toString() + '_' + si.toString())
|
||||
}
|
||||
.fontSize(14)
|
||||
.lineHeight(22)
|
||||
.width('100%')
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
}, (ln: string, idx: number) => 'bq' + idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.margin({ top: 3, bottom: 5 })
|
||||
.borderRadius({ topLeft: 0, topRight: 6, bottomRight: 6, bottomLeft: 0 })
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({
|
||||
width: { left: 3, top: 0, right: 0, bottom: 0 },
|
||||
color: { left: this.palette().accent, top: Color.Transparent, right: Color.Transparent, bottom: Color.Transparent },
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
HrBlock() {
|
||||
Row()
|
||||
.width('100%')
|
||||
.height(1)
|
||||
.backgroundColor(this.palette().kvBorder)
|
||||
.margin({ top: 6, bottom: 6 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
TableBlock(block: MdBlock) {
|
||||
Column() {
|
||||
// Header
|
||||
Row() {
|
||||
ForEach(block.headers ?? [], (h: string, hi: number) => {
|
||||
Text() {
|
||||
ForEach(parseInline(h), (sp: MdSpan, si: number) => {
|
||||
Span(sp.text)
|
||||
.fontSize(12.5)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
}, (sp: MdSpan, si: number) => 'th' + hi.toString() + '_' + si.toString())
|
||||
}
|
||||
.fontSize(12.5)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 6, right: 6, top: 5, bottom: 5 })
|
||||
}, (h: string, hi: number) => 'th' + hi.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
|
||||
// Body
|
||||
ForEach(block.rows ?? [], (row: string[], ri: number) => {
|
||||
Row() {
|
||||
ForEach(row, (cell: string, ci: number) => {
|
||||
Text() {
|
||||
ForEach(parseInline(cell), (sp: MdSpan, si: number) => {
|
||||
Span(sp.text)
|
||||
.fontSize(12.5)
|
||||
.fontColor(this.palette().msgBubbleText)
|
||||
.fontFamily(sp.code === true ? 'monospace' : '-')
|
||||
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
|
||||
}, (sp: MdSpan, si: number) => 'td' + ri.toString() + '_' + ci.toString() + '_' + si.toString())
|
||||
}
|
||||
.fontSize(12.5)
|
||||
.fontColor(this.palette().msgBubbleText)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 6, right: 6, top: 4, bottom: 4 })
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.width('100%')
|
||||
}, (cell: string, ci: number) => 'td' + ri.toString() + '_' + ci.toString())
|
||||
}
|
||||
.width('100%')
|
||||
}, (row: string[], ri: number) => 'tr' + ri.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.borderRadius(RADIUS_SM)
|
||||
.border({ width: 1, color: this.palette().kvBorder })
|
||||
.clip(true)
|
||||
.margin({ top: 4, bottom: 6 })
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private splitNewlines(text: string): string[] {
|
||||
if (text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return text.split('\n');
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
ForEach(this.blocks, (b: MdBlock, idx: number) => {
|
||||
if (b.type === 'code') {
|
||||
this.CodeBlock(b)
|
||||
} else if (b.type === 'heading') {
|
||||
this.HeadingBlock(b)
|
||||
} else if (b.type === 'list') {
|
||||
this.ListBlock(b)
|
||||
} else if (b.type === 'ol') {
|
||||
this.OlBlock(b)
|
||||
} else if (b.type === 'blockquote') {
|
||||
this.BlockquoteBlock(b.text ?? '')
|
||||
} else if (b.type === 'hr') {
|
||||
this.HrBlock()
|
||||
} else if (b.type === 'table') {
|
||||
this.TableBlock(b)
|
||||
} else {
|
||||
this.ParaBlock(b.text ?? '')
|
||||
}
|
||||
}, (b: MdBlock, idx: number) => idx.toString() + b.type + ((b.text ?? '').substring(0, 12)))
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
341
cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets
Normal file
341
cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets
Normal file
@ -0,0 +1,341 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM,
|
||||
ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { RuntimePanel } from './RuntimePanel';
|
||||
import {
|
||||
statusStore, StatGroup, StatField, compactDuration,
|
||||
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, K_BUILD,
|
||||
} from '../common/StatusStore';
|
||||
|
||||
/**
|
||||
* 运行状态摘要卡:嵌在设置一级页顶部(原「状态」Tab 已删除)。
|
||||
*
|
||||
* 只放一眼可读的东西:环形仪表 + 运行时长 + 三项 KPI + 版本,
|
||||
* 明细(内核 / 模型 / 记忆 / 运行时 / 工具清单)走二级页面。
|
||||
* 点击整卡进入明细,所以自身不放任何按钮,避免嵌套点击。
|
||||
*/
|
||||
@Component
|
||||
export struct StatusSummaryCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp(K_UP) private up: boolean = false;
|
||||
@StorageProp(K_VERSION) private version: string = '-';
|
||||
/** 内核名 · commit。与版本号分开一行:版本号本身没有内核身份。 */
|
||||
@StorageProp(K_BUILD) private buildSub: string = '';
|
||||
@StorageProp(K_STARTED) private startedAt: string = '';
|
||||
@StorageProp(K_AGENTS) private agents: number = 0;
|
||||
@StorageProp(K_PLUGINS) private plugins: number = 0;
|
||||
@StorageProp(K_TOOLS) private tools: number = 0;
|
||||
@StorageProp(K_ERR) private err: string = '';
|
||||
@StorageProp(K_LOADING) private loading: boolean = false;
|
||||
/** 秒级刷新的运行时长文本:环心不能用 startedAt 直接算,否则不会自动跳秒 */
|
||||
@State private uptime: string = '-';
|
||||
private timerId: number = -1;
|
||||
onTap?: () => void;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.uptime = compactDuration(this.startedAt);
|
||||
this.timerId = setInterval(() => {
|
||||
this.uptime = compactDuration(this.startedAt);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
if (this.timerId >= 0) {
|
||||
clearInterval(this.timerId);
|
||||
this.timerId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
// 整卡按压反馈统一收进 MotionBase(父组件):这里是进明细页的唯一入口,
|
||||
// 父组件负责 scale 回弹;导航语义仍由本卡自己的 onClick(点击释放)负责,
|
||||
// 避免"按下即跳转"的误触手感。
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Column() {
|
||||
if (this.err.length > 0) {
|
||||
// 连接失败:只显示一句人话,技术细节在 hilog 里
|
||||
Row() {
|
||||
Image($r('app.media.ic_error'))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.fillColor(COLOR_ERROR)
|
||||
.draggable(false)
|
||||
Text(this.err)
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.layoutWeight(1)
|
||||
.margin({ left: 10 })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
} else if (this.loading && !this.up) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(26)
|
||||
.height(26)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 20, bottom: 20 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
} else {
|
||||
// 状态主行:一枚状态图标(运行=脉搏,离线=断开)+ 状态词 + 运行时长。
|
||||
// 之前那个环形进度条表达的是"100% / 0%",而在线与否是布尔量,
|
||||
// 用百分比环表示只会让人以为有什么在加载;架构是单 agent 单 session,
|
||||
// "Agent 数" 恒为 1,也没有信息量,一并去掉。
|
||||
Row({ space: 12 }) {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
Image(this.up ? $r('app.media.ic_pulse') : $r('app.media.ic_offline'))
|
||||
.width(24)
|
||||
.height(24)
|
||||
.fillColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.draggable(false)
|
||||
}
|
||||
.width(48)
|
||||
.height(48)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.up ? 'rgba(23, 169, 100, 0.16)' : this.palette().bgHover)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
|
||||
Column({ space: 3 }) {
|
||||
Text(this.up ? '运行中' : '离线')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
Text(this.up ? '已运行 ' + this.uptime : '未连接到后端服务')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
|
||||
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)
|
||||
Row({ space: 10 }) {
|
||||
this.kpiTile($r('app.media.ic_plug'), '插件', this.plugins, COLOR_ACCENT)
|
||||
this.kpiTile($r('app.media.ic_tool'), '工具', this.tools, COLOR_CYAN)
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 14 })
|
||||
|
||||
// 版本 + 「查看明细」提示,与 KPI 之间用分割线断开
|
||||
Row() {
|
||||
Text('版本')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
Column() {
|
||||
Text(this.version)
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
// 内核名 + commit:光有版本号会分不清是哪个内核、哪次构建
|
||||
Text(this.buildSub)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.visibility(this.buildSub.length > 0 ? Visibility.Visible : Visibility.None)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
.margin({ left: 10 })
|
||||
Text('明细')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().accent)
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(14)
|
||||
.height(14)
|
||||
.fillColor(this.palette().accent)
|
||||
.draggable(false)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.margin({ top: 14 })
|
||||
.padding({ top: 11 })
|
||||
.border({ width: { top: 1 }, color: this.palette().kvBorder })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 18 })
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
kpiTile(icon: Resource, label: string, value: number, color: string) {
|
||||
Row({ space: 8 }) {
|
||||
Image(icon)
|
||||
.width(16)
|
||||
.height(16)
|
||||
.fillColor(color)
|
||||
.draggable(false)
|
||||
Column({ space: 1 }) {
|
||||
Text(value > 0 ? value.toString() : '-')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行状态明细:设置页「运行状态」二级页面的内容。
|
||||
*
|
||||
* 服务负载三条容量条 + 分组明细卡(系统概览 / 内核 / 模型 / 记忆 / 运行时 / 工具)。
|
||||
* 分组数组不放 AppStorage(数组同步语义不可靠),改为订阅版本号 K_REV,
|
||||
* 版本变化时从 statusStore 重取一次快照。
|
||||
*/
|
||||
@Component
|
||||
export struct StatusDetailContent {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp(K_UP) private up: boolean = false;
|
||||
@StorageProp(K_AGENTS) private agents: number = 0;
|
||||
@StorageProp(K_PLUGINS) private plugins: number = 0;
|
||||
@StorageProp(K_TOOLS) private tools: number = 0;
|
||||
@StorageProp(K_ERR) private err: string = '';
|
||||
@StorageProp(K_LOADING) private loading: boolean = false;
|
||||
@StorageProp(K_REV) @Watch('onRevChanged') private rev: number = 0;
|
||||
@State groups: StatGroup[] = [];
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.groups = statusStore.getGroups();
|
||||
}
|
||||
|
||||
private onRevChanged(): void {
|
||||
this.groups = statusStore.getGroups();
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.err.length > 0) {
|
||||
Row() {
|
||||
Image($r('app.media.ic_error'))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.fillColor(COLOR_ERROR)
|
||||
.draggable(false)
|
||||
Text(this.err)
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.layoutWeight(1)
|
||||
.margin({ left: 10 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.margin({ bottom: 14 })
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (this.loading && this.groups.length === 0) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(28)
|
||||
.height(28)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 40, bottom: 40 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
// 这里原本有一张"服务负载"卡,用两条容量条画插件/工具数量。
|
||||
// 后端并不存在"插件上限/工具上限"这种容量概念,分母是两者取大值,
|
||||
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
|
||||
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
|
||||
|
||||
// 运行态面板(阶段管道 + 中断队列)放在明细页最前:
|
||||
// 明细卡回答「内核有哪些东西、多少」,运行态回答「现在在干什么」,
|
||||
// 后者是进这个页面最先想看的。
|
||||
RuntimePanel()
|
||||
|
||||
ForEach(this.groups, (g: StatGroup) => {
|
||||
Column() {
|
||||
Text(g.title)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.margin({ bottom: 6 })
|
||||
ForEach(g.fields, (f: StatField, idx: number) => {
|
||||
Row() {
|
||||
Text(f.label)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(f.value.length > 0 ? f.value : '-')
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: 200 })
|
||||
.margin({ left: 16 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 9, bottom: 9 })
|
||||
.border({
|
||||
width: { bottom: idx < g.fields.length - 1 ? 1 : 0 },
|
||||
color: this.palette().kvBorder,
|
||||
})
|
||||
}, (f: StatField) => g.title + f.label)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 14 })
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}, (g: StatGroup) => g.title)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
364
cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets
Normal file
364
cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets
Normal file
@ -0,0 +1,364 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG,
|
||||
ANIM_NORMAL } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from './PageTopBar';
|
||||
import { GradientBackground } from './GradientBackground';
|
||||
|
||||
/**
|
||||
* 二级页面开关标志(AppStorage)。
|
||||
* Index 读取它来锁住 Swiper 左右滑动——否则在二级页面里横滑会误切换主 Tab。
|
||||
* 由 Navigation.onNavBarStateChange 驱动,与真实导航栈严格同步。
|
||||
*/
|
||||
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
|
||||
|
||||
export function markSubPageOpen(open: boolean): void {
|
||||
if (AppStorage.has(KEY_SUBPAGE_OPEN)) {
|
||||
AppStorage.set<boolean>(KEY_SUBPAGE_OPEN, open);
|
||||
} else {
|
||||
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
|
||||
}
|
||||
}
|
||||
|
||||
/** pushPathByName 的参数载体:ArkTS 不允许把 string 断言成 object */
|
||||
export interface SubPageParam {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function subPageParam(id: string): SubPageParam {
|
||||
const p: SubPageParam = { id: id };
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级页面内容层:放在 NavDestination 里使用。
|
||||
*
|
||||
* 为什么二级页面走 Navigation / NavDestination 而不是自己用 @State 切换:
|
||||
* - NavDestination 由系统维护导航栈,侧滑返回、三键返回、
|
||||
* 以及无障碍返回都会自动 pop,不需要自己拦 onBackPress;
|
||||
* - 转场动画由系统提供,与其他系统应用一致。
|
||||
*
|
||||
* 视觉约定与一级页面完全一致:
|
||||
* - 自带同款渐变背景(NavDestination 会整屏盖住一级内容,
|
||||
* 不铺背景会透出下层列表形成重影);
|
||||
* - 顶栏只有标题,上边缘由不透明渐变到透明;
|
||||
* - 返回按钮不放顶栏,而是作为独立悬浮组件放在底部悬浮区(拇指可达)。
|
||||
*
|
||||
* 注意:@BuilderParam 只能有一个(trailing lambda 限制),
|
||||
* 所以悬浮区的刷新按钮用 showRefresh + onRefresh 两个普通属性表达,
|
||||
* 而不是第二个 @BuilderParam。
|
||||
*/
|
||||
@Component
|
||||
export struct SubPageLayer {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 宽屏分栏时本层就是右侧栏,一级界面一直在左边可见,返回键无意义 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
@Prop title: string = '';
|
||||
/** 所属主 Tab 序号,供底部悬浮区的显隐动画使用 */
|
||||
@Prop tab: number = 0;
|
||||
/** 悬浮区是否附带刷新按钮 */
|
||||
@Prop showRefresh: boolean = false;
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
// 二级页面自带背景:Stack 模式下整屏覆盖,必须自己铺底;
|
||||
// 宽屏分栏时右栏也需要同款背景,与左栏视觉连续。
|
||||
GradientBackground()
|
||||
|
||||
Scroll() {
|
||||
Column() {
|
||||
this.content()
|
||||
}
|
||||
.width('100%')
|
||||
// 宽屏右栏底部没有主导航胶囊,只保留悬浮键的空间
|
||||
.padding({ left: 16, right: 16, top: 76, bottom: this.isWide ? 108 : 174 })
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.Top)
|
||||
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
||||
handleNavOnScroll(state);
|
||||
})
|
||||
|
||||
PageTopBar({ title: this.title })
|
||||
|
||||
// 底部悬浮区:返回键(+ 可选刷新键)。
|
||||
// 宽屏分栏时左栏常驻可见,一级页面自己的悬浮刷新键也还在屏幕上,
|
||||
// 这里再挂一个就成了"两个重加载按钮"——所以宽屏下本层不出刷新键,
|
||||
// 由一级页面那一个统一负责(它的回调会连带刷新右栏内容)。
|
||||
NavFloatOverlay({ tab: this.tab }) {
|
||||
NavFloatRow() {
|
||||
if (!this.isWide) {
|
||||
// 自定义组件不能直接挂 .transition()(会生成 __Common__ 包装节点),
|
||||
// 所以各自套一层无 padding 的 Column,由它承载出入场动画,
|
||||
// 让悬浮键与 400ms 平滑滑出的悬浮层不再"一帧硬切"。
|
||||
Column() {
|
||||
SubPageBackButton({
|
||||
onTap: () => {
|
||||
const cb = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
if (this.showRefresh) {
|
||||
Column() {
|
||||
FloatIconButton({
|
||||
icon: $r('app.media.ic_refresh'),
|
||||
onTap: () => {
|
||||
const cb = this.onRefresh;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一级页面里的入口行:图标 + 标题 + 副标题 + 右侧摘要值 + 右尖角。
|
||||
* 点击进入二级页面。
|
||||
*/
|
||||
@Component
|
||||
export struct NavRow {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop icon: Resource | undefined = undefined;
|
||||
@Prop title: string = '';
|
||||
@Prop subtitle: string = '';
|
||||
@Prop value: string = '';
|
||||
@Prop tint: string = '';
|
||||
@Prop showDivider: boolean = true;
|
||||
/** 宽屏分栏时右栏正显示本行对应的二级页面 —— 高亮当前项 */
|
||||
@Prop selected: boolean = false;
|
||||
onTap?: () => void;
|
||||
|
||||
build() {
|
||||
// 通用按压动画收进 MotionBase(父组件):scale 回弹统一。
|
||||
// 选中态底色仍在这里管:它是 NavRow 特有外观,不归父组件。
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Row() {
|
||||
if (this.icon !== undefined) {
|
||||
Row() {
|
||||
Image(this.icon)
|
||||
.width(17)
|
||||
.height(17)
|
||||
.fillColor(this.iconColor())
|
||||
.draggable(false)
|
||||
}
|
||||
.width(32)
|
||||
.height(32)
|
||||
.borderRadius(10)
|
||||
.backgroundColor(this.iconBg())
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.margin({ right: 12 })
|
||||
}
|
||||
|
||||
Column({ space: 2 }) {
|
||||
Text(this.title)
|
||||
.fontSize(15)
|
||||
.fontColor(this.selected ? this.palette().accent : this.palette().textPrimary)
|
||||
// 父行 MotionBase 的 .animation() 到不了子节点,选中态字色自己缓动
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.fontWeight(this.selected ? FontWeight.Medium : FontWeight.Normal)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (this.subtitle.length > 0) {
|
||||
Text(this.subtitle)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
if (this.value.length > 0) {
|
||||
Text(this.value)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: 130 })
|
||||
.margin({ right: 8 })
|
||||
}
|
||||
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.selected ? this.palette().accent : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.draggable(false)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
// 选中态底色:写在 .border 之前,避免分割线宽度被纳入动画。
|
||||
// MotionBase 的 .animation() 只作用在包装 Column 上,到不了这里,
|
||||
// 选中高亮的底色迁移要本行自己声明。
|
||||
.backgroundColor(this.selected ? this.palette().accentBg : Color.Transparent)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.border({
|
||||
width: { bottom: this.showDivider ? 1 : 0 },
|
||||
color: this.palette().kvBorder,
|
||||
})
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private iconColor(): string {
|
||||
return this.tint.length > 0 ? this.tint : this.palette().accent;
|
||||
}
|
||||
|
||||
private iconBg(): string {
|
||||
return this.tint.length > 0 ? this.palette().bgHover : this.palette().accentBg;
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组卡:一个标题 + 一组 NavRow,圆角裁剪让行分割线不越出卡片。
|
||||
*/
|
||||
@Component
|
||||
export struct NavGroup {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop caption: string = '';
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.caption.length > 0) {
|
||||
Text(this.caption)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ left: 4, bottom: 8 })
|
||||
}
|
||||
Column() {
|
||||
this.content()
|
||||
}
|
||||
.width('100%')
|
||||
.borderRadius(RADIUS_LG)
|
||||
.clip(true)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 18 })
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用内容卡(二级页面里承载表单/明细的容器)。
|
||||
*/
|
||||
@Component
|
||||
export struct PlainCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop caption: string = '';
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.caption.length > 0) {
|
||||
Text(this.caption)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ left: 4, bottom: 8 })
|
||||
}
|
||||
Column() {
|
||||
this.content()
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 18 })
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级页面的返回按钮:与悬浮图标按钮同款玻璃,放在底部悬浮区最左侧。
|
||||
* 系统侧滑返回同样可用,这个按钮只是给单手拇指多一条路径。
|
||||
*/
|
||||
@Component
|
||||
export struct SubPageBackButton {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
onTap?: () => void;
|
||||
|
||||
build() {
|
||||
// 玻璃底自带的按压变暗在 45% 透明度上几乎看不见,缩放反馈由 MotionBase 统一提供。
|
||||
// fillWidth: false —— 悬浮区按内容宽度排列,不能撑满整行。
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button() {
|
||||
Row({ space: 4 }) {
|
||||
Image($r('app.media.ic_arrow_back'))
|
||||
.width(17)
|
||||
.height(17)
|
||||
.fillColor(this.palette().textSecondary)
|
||||
.draggable(false)
|
||||
}
|
||||
}
|
||||
.width(42)
|
||||
.height(42)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.border({
|
||||
width: { left: 1, top: 1, right: 1, bottom: 1 },
|
||||
color: this.palette().navBarBorder,
|
||||
})
|
||||
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 右下角轻提示条(设置页与插件页共用)。
|
||||
*
|
||||
* 两处原本各写一份,只有"描边"这一处不同:设置页无描边,插件页有。
|
||||
* 用 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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
import { UIAbility, AbilityConstant, Configuration, ConfigurationConstant, Want } from '@kit.AbilityKit';
|
||||
import { window } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants';
|
||||
import { startForegroundBridge, stopForegroundBridge } from '../common/DeviceBridgeSession';
|
||||
|
||||
/** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */
|
||||
function storedThemeMode(): string {
|
||||
try {
|
||||
const s = connStore.getSettings();
|
||||
if (typeof s.theme === 'string' && s.theme.length > 0) {
|
||||
return s.theme;
|
||||
}
|
||||
} catch (e) {
|
||||
// store not ready yet
|
||||
}
|
||||
return 'system';
|
||||
}
|
||||
|
||||
export default class EntryAbility extends UIAbility {
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
console.info('[HomeAgent] ability onCreate');
|
||||
const sysDark: boolean =
|
||||
this.context.config?.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
|
||||
// Seed the OS color state first, then resolve the persisted mode.
|
||||
// connStore may not be initialized here (mode falls back to 'system');
|
||||
// onWindowStageCreate re-applies the real stored mode after init.
|
||||
seedSystemIsDark(sysDark);
|
||||
seedTheme(resolveIsDark(storedThemeMode(), sysDark));
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
stopForegroundBridge();
|
||||
console.info('[HomeAgent] ability onDestroy');
|
||||
}
|
||||
|
||||
/** System dark/light switch — re-resolve when mode is 'system'. */
|
||||
onConfigurationUpdate(newConfig: Configuration): void {
|
||||
try {
|
||||
const sysDark: boolean = newConfig.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
|
||||
seedSystemIsDark(sysDark);
|
||||
const mode: string = storedThemeMode();
|
||||
if (mode === 'system') {
|
||||
AppStorage.set('themeIsDark', resolveIsDark('system', sysDark));
|
||||
this.applySystemBar();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private applySystemBar(): void {
|
||||
// 状态栏与窗口底色跟随主题,消除浅色模式下的暗色割裂
|
||||
const dark: boolean = themeIsDark();
|
||||
const bg: string = dark ? '#000000' : '#F1F3F5';
|
||||
const fg: string = dark ? '#FFFFFF' : '#191919';
|
||||
try {
|
||||
window.getLastWindow(this.context).then((win: window.Window) => {
|
||||
win.setWindowSystemBarProperties({
|
||||
statusBarColor: bg,
|
||||
statusBarContentColor: fg,
|
||||
navigationBarColor: bg,
|
||||
});
|
||||
win.setWindowBackgroundColor(bg);
|
||||
}).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
onWindowStageCreate(windowStage: window.WindowStage): void {
|
||||
const startUI = (): void => {
|
||||
windowStage.loadContent('pages/Index', () => {
|
||||
console.info('[HomeAgent] main page loaded');
|
||||
});
|
||||
};
|
||||
|
||||
// 沉浸式窗口 + 主题化系统栏,消除真机顶部/底部白边
|
||||
windowStage.getMainWindow((err: BusinessError, win: window.Window) => {
|
||||
if (err.code !== 0) {
|
||||
connStore.init(this.context).then(async () => {
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.reapplyStoredTheme();
|
||||
startForegroundBridge(this.context);
|
||||
startUI();
|
||||
}).catch(() => {
|
||||
startUI();
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 全屏沉浸:内容延伸到状态栏和导航栏区域
|
||||
win.setWindowLayoutFullScreen(true);
|
||||
} catch (e) {
|
||||
console.warn('[HomeAgent] setWindowLayoutFullScreen failed: ' + (e as Error).message);
|
||||
}
|
||||
connStore.init(this.context).then(async () => {
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
console.info('[HomeAgent] connStore initialized');
|
||||
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
|
||||
this.reapplyStoredTheme();
|
||||
this.applySystemBar();
|
||||
startForegroundBridge(this.context);
|
||||
startUI();
|
||||
}).catch((e: Error) => {
|
||||
console.error('[HomeAgent] connStore init failed: ' + e.message);
|
||||
startUI();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** After connStore.init, re-resolve themeIsDark from the persisted mode. */
|
||||
private reapplyStoredTheme(): void {
|
||||
try {
|
||||
applyThemeMode(storedThemeMode());
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
onWindowStageDestroy(): void {
|
||||
console.info('[HomeAgent] ability onWindowStageDestroy');
|
||||
}
|
||||
|
||||
onForeground(): void {
|
||||
startForegroundBridge(this.context);
|
||||
console.info('[HomeAgent] ability onForeground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
stopForegroundBridge();
|
||||
console.info('[HomeAgent] ability onBackground');
|
||||
}
|
||||
}
|
||||
208
cmd/ohos/HomeAgent/entry/src/main/ets/model/Model.ets
Normal file
208
cmd/ohos/HomeAgent/entry/src/main/ets/model/Model.ets
Normal file
@ -0,0 +1,208 @@
|
||||
export interface ConnectionConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
apiKey: string;
|
||||
type: string; // 'webui' | 'cli'
|
||||
}
|
||||
|
||||
export interface ToolCallInfo {
|
||||
name: string;
|
||||
args: string;
|
||||
result?: string;
|
||||
plugin?: string;
|
||||
status?: string; // 'ok' | 'error' | 'denied' | 'running'
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number;
|
||||
role: string; // 'user' | 'assistant'
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
reasoningOpen?: boolean;
|
||||
isStreaming?: boolean;
|
||||
isFinal?: boolean;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
|
||||
source?: string;
|
||||
/** 图片/文件附件(后端 ChatMsg.attachment) */
|
||||
attachment?: ChatAttachment;
|
||||
}
|
||||
|
||||
export interface HistoryMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
source?: string;
|
||||
timestamp?: string;
|
||||
attachment?: ChatAttachment;
|
||||
}
|
||||
|
||||
export interface AgentStatus {
|
||||
version?: string;
|
||||
uptime_ms?: number;
|
||||
agent?: string;
|
||||
provider?: string;
|
||||
memory_events?: number;
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
online: boolean;
|
||||
authorized: boolean;
|
||||
caps: string[];
|
||||
hostname?: string;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
export interface PluginInfo {
|
||||
name: string;
|
||||
nameZh?: string;
|
||||
nameEn?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
deprecated?: boolean;
|
||||
builtin: boolean;
|
||||
loaded: boolean;
|
||||
disabled: boolean;
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the merged plugin list:
|
||||
* kernel.plugins ∪ installed(/plugins) ∪ disabled(/plugins/disabled),
|
||||
* 与 WebGUI renderPlugins 的合并口径一致。
|
||||
*/
|
||||
export interface PluginRow {
|
||||
name: string;
|
||||
/** /kernel.plugins 里出现且 loaded=true 才算已加载 */
|
||||
loaded: boolean;
|
||||
/** 出现在 /plugins/disabled 列表里 */
|
||||
disabled: boolean;
|
||||
/** 已安装的外部插件(/plugins 返回) */
|
||||
external: boolean;
|
||||
version?: string;
|
||||
description?: string;
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /plugins/{name} 返回的插件清单明细。
|
||||
* 字段与后端 pluginmgr 的 pluginInfo 一一对应;
|
||||
* WebGUI 只把它 JSON.stringify 到 <pre> 里,这里改为结构化展示。
|
||||
*/
|
||||
export interface PluginDetail {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
license: string;
|
||||
homepage: string;
|
||||
repository: string;
|
||||
entry: string;
|
||||
minVersion: string;
|
||||
tags: string[];
|
||||
deprecated: boolean;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export function emptyPluginDetail(): PluginDetail {
|
||||
const d: PluginDetail = {
|
||||
name: '',
|
||||
version: '',
|
||||
description: '',
|
||||
author: '',
|
||||
license: '',
|
||||
homepage: '',
|
||||
repository: '',
|
||||
entry: '',
|
||||
minVersion: '',
|
||||
tags: [],
|
||||
deprecated: false,
|
||||
files: [],
|
||||
};
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息附件。字段与后端 webui 的 Attachment 严格一致,只有四个:
|
||||
* type / url / size / name —— 后端不提供 mime、尺寸、本地路径。
|
||||
*/
|
||||
export interface ChatAttachment {
|
||||
/** 'image' | 'file' */
|
||||
type: string;
|
||||
/** /files/<name>、/uploads/<name> 或远程 http(s) 地址 */
|
||||
url: string;
|
||||
/** 字节数;远程 URL 为 0 */
|
||||
size: number;
|
||||
/** 展示用文件名 */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
lang: string; // 'zh' | 'en'
|
||||
theme: string; // 'dark' | 'light' | 'system'
|
||||
currentConnId: string;
|
||||
/** 自定义背景图路径(应用沙箱内文件路径),空串表示未设置 */
|
||||
bgImage: string;
|
||||
/** 背景图不透明度 0..1 */
|
||||
bgOpacity: number;
|
||||
}
|
||||
|
||||
export function emptySettings(): AppSettings {
|
||||
const s: AppSettings = {
|
||||
lang: 'zh',
|
||||
theme: 'dark',
|
||||
currentConnId: '',
|
||||
bgImage: '',
|
||||
bgOpacity: 0.25,
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化用户填写的后端地址。
|
||||
*
|
||||
* 用户习惯直接敲域名(homeagent.example.xyz)或粘贴带路径的地址。
|
||||
* 三件事必须在存库前做掉,否则请求会以各种方式失败:
|
||||
*
|
||||
* 1) 补协议,且默认 https。
|
||||
* 很多反代把 http 整站 302 到别的主机(例如门户站 www.xxx),
|
||||
* 重定向后的主机没有 API,客户端只会拿到 404 页面而不是 401/200,
|
||||
* 表现为"连接直接失败"且看不出原因。默认 https 可以绕过整类问题。
|
||||
* 只有明确写了 http:// 的内网地址才走明文。
|
||||
* 2) 去掉结尾斜杠,避免拼出 //api/v1。
|
||||
* 3) 去掉用户误粘的 /api/v1 后缀,避免拼成 /api/v1/api/v1。
|
||||
*/
|
||||
export function normalizeBaseUrl(raw: string): string {
|
||||
let u: string = raw.trim();
|
||||
if (u.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const lower: string = u.toLowerCase();
|
||||
if (!lower.startsWith('http://') && !lower.startsWith('https://')) {
|
||||
u = 'https://' + u;
|
||||
}
|
||||
u = u.replace(/\/+$/, '');
|
||||
u = u.replace(/\/api\/v1$/, '');
|
||||
return u;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建连接用的空白模板。
|
||||
* 这里不能写死任何地址或密钥:源码是公开的,硬编码等于把内网地址和访问凭据一起发布。
|
||||
* 地址与 API Key 由用户在「设置 - 连接」里填写,未配置时各页面统一提示(见 UserError.MSG_NO_CONN)。
|
||||
*/
|
||||
export function defaultConnection(): ConnectionConfig {
|
||||
const c: ConnectionConfig = {
|
||||
id: '',
|
||||
name: 'HomeAgent',
|
||||
url: '',
|
||||
apiKey: '',
|
||||
type: 'webui',
|
||||
};
|
||||
return c;
|
||||
}
|
||||
207
cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets
Normal file
207
cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets
Normal file
@ -0,0 +1,207 @@
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { ChatAttachment } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
||||
import { ChatStream } from '../components/ChatStream';
|
||||
import { ChatComposer } from '../components/ChatComposer';
|
||||
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { AttachmentDetailContent } from '../components/Attachment';
|
||||
import { chatStore } from '../common/ChatStore';
|
||||
|
||||
/** 二级页面标识:附件(图片/文件)详情 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_ATTACHMENT: string = 'attachment';
|
||||
|
||||
/**
|
||||
* 聊天页:只做"页面壳"。
|
||||
*
|
||||
* 拆分后的分工(拆分前这里是 1962 行的单文件):
|
||||
* - 消息流状态机(SSE / 历史 / 新消息动画)→ common/ChatStore.ets
|
||||
* - SSE 事件翻译 → common/ChatSse.ets
|
||||
* - 发送与中断 → common/ChatSession.ets
|
||||
* - 消息列表 + 滚动 → components/ChatStream.ets
|
||||
* - 气泡(含思考卡/工具卡) → components/ChatBubble.ets
|
||||
* - 悬浮输入区 + 附件选择/上传 → components/ChatComposer.ets
|
||||
* 本文件保留:导航栈与附件详情二级页、以及"列表底部留白"这个
|
||||
* 输入区与列表之间的耦合点(所以三个相关标志位用 @Link 与输入区共享)。
|
||||
*/
|
||||
@Component
|
||||
export struct ChatPage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 宽屏:左边聊天流(含底部导航栏),右边附件详情 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
/** 当前在右栏/二级页展示的附件;未打开时为 undefined */
|
||||
@State activeAtt: ChatAttachment | undefined = undefined;
|
||||
@State activeSub: string = SUB_NONE;
|
||||
/** 以下三项影响列表底部留白,与输入区共享(输入区负责改) */
|
||||
@State inputMultiLine: boolean = false;
|
||||
@State attachMenuOpen: boolean = false;
|
||||
@State pendingName: string = '';
|
||||
private navStack: NavPathStack = new NavPathStack();
|
||||
|
||||
aboutToAppear(): void {
|
||||
// 把本页导航栈登记给 Index:返回手势/三键返回按当前 Tab 精确派发到这里
|
||||
registerNavStack(0, this.navStack, () => {
|
||||
this.activeSub = SUB_NONE;
|
||||
this.activeAtt = undefined;
|
||||
});
|
||||
chatStore.init();
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
chatStore.loadHistory();
|
||||
chatStore.connect();
|
||||
}
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
unregisterNavStack(0);
|
||||
chatStore.disconnect();
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息列表底部留白:悬浮输入区(导航栏上方 94 + 输入行 44 ≈ 138)
|
||||
* 之上再留 ~72 的安全间距,保证最后一条气泡的思考卡/工具卡/附件
|
||||
* 不被遮挡;加号菜单与待发送附件预览展开时再加高。
|
||||
*/
|
||||
private listBottomPad(): number {
|
||||
let pad: number = 210;
|
||||
if (this.inputMultiLine) {
|
||||
pad += 60;
|
||||
}
|
||||
if (this.attachMenuOpen) {
|
||||
pad += 70;
|
||||
}
|
||||
if (this.pendingName.length > 0) {
|
||||
pad += 46;
|
||||
}
|
||||
return pad;
|
||||
}
|
||||
|
||||
// ===================== 二级页面:附件详情 =====================
|
||||
|
||||
/**
|
||||
* 打开附件详情。
|
||||
* WebGUI 点图片是新开浏览器标签、点文件是直接下载;
|
||||
* 移动端没有"标签页",改成一个二级页面:大图预览 + 元信息 + 保存。
|
||||
*/
|
||||
private openAttachment(att: ChatAttachment): void {
|
||||
this.activeAtt = att;
|
||||
this.activeSub = SUB_ATTACHMENT;
|
||||
if (this.isWide && this.navStack.size() > 0) {
|
||||
this.navStack.replacePathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), false);
|
||||
} else {
|
||||
this.navStack.pushPathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), true);
|
||||
}
|
||||
}
|
||||
|
||||
private closeAttachment(): void {
|
||||
this.navStack.pop();
|
||||
this.activeSub = SUB_NONE;
|
||||
this.activeAtt = undefined;
|
||||
}
|
||||
|
||||
build() {
|
||||
// 宽屏:左栏聊天流(含底部导航与输入区),右栏附件详情。
|
||||
// 窄屏:附件详情整屏覆盖,系统返回手势直接作用于 navStack。
|
||||
Navigation(this.navStack) {
|
||||
this.ChatBody()
|
||||
}
|
||||
.navDestination(this.SubDestination)
|
||||
.mode(this.isWide ? NavigationMode.Split : NavigationMode.Stack)
|
||||
.navBarPosition(NavBarPosition.Start)
|
||||
.navBarWidth(WIDE_NAV_BAR_WIDTH)
|
||||
.minContentWidth(WIDE_MIN_CONTENT)
|
||||
.hideTitleBar(true)
|
||||
.hideToolBar(true)
|
||||
.hideBackButton(true)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.onNavBarStateChange((isVisible: boolean) => {
|
||||
markSubPageOpen(this.isWide || !isVisible);
|
||||
})
|
||||
.onNavigationModeChange((mode: NavigationMode) => {
|
||||
if (mode === NavigationMode.Split) {
|
||||
markSubPageOpen(true);
|
||||
// 右栏不能空白:有附件就展示最近一个,没有则由 SubDestination 显示空态
|
||||
if (this.navStack.size() === 0) {
|
||||
const a: ChatAttachment | undefined = chatStore.latestAttachment();
|
||||
this.activeAtt = a;
|
||||
this.activeSub = SUB_ATTACHMENT;
|
||||
this.navStack.pushPathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), false);
|
||||
}
|
||||
} else {
|
||||
this.navStack.clear(false);
|
||||
this.activeSub = SUB_NONE;
|
||||
markSubPageOpen(false);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 附件详情二级页面 */
|
||||
@Builder
|
||||
SubDestination(name: string, param: object) {
|
||||
NavDestination() {
|
||||
if (name === SUB_ATTACHMENT) {
|
||||
SubPageLayer({
|
||||
title: '附件详情',
|
||||
tab: 0,
|
||||
onBack: () => {
|
||||
this.closeAttachment();
|
||||
},
|
||||
}) {
|
||||
this.AttachmentPane()
|
||||
}
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
|
||||
@Builder
|
||||
AttachmentPane() {
|
||||
if (this.activeAtt !== undefined) {
|
||||
AttachmentDetailContent({ att: this.activeAtt })
|
||||
} else {
|
||||
// 宽屏右栏空态:聊天里还没有任何图片/文件
|
||||
Column({ space: 8 }) {
|
||||
Image($r('app.media.ic_file'))
|
||||
.width(38)
|
||||
.height(38)
|
||||
.fillColor(this.palette().textMuted)
|
||||
.draggable(false)
|
||||
Text('选择聊天中的图片或文件查看详情')
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 60 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
ChatBody() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
// 层1~1.6:消息流 + 顶栏遮罩 + 底部淡出遮罩
|
||||
ChatStream({
|
||||
bottomPad: this.listBottomPad(),
|
||||
onOpenAttachment: (att: ChatAttachment) => {
|
||||
this.openAttachment(att);
|
||||
},
|
||||
})
|
||||
|
||||
// 层2:悬浮输入区(导航栏上方,与导航栏左右平齐,各组件独立不共框)
|
||||
ChatComposer({
|
||||
inputMultiLine: $inputMultiLine,
|
||||
attachMenuOpen: $attachMenuOpen,
|
||||
pendingName: $pendingName,
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
307
cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets
Normal file
307
cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets
Normal file
@ -0,0 +1,307 @@
|
||||
import { deviceBridge } from '../common/DeviceBridge';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH,
|
||||
WIDE_MIN_CONTENT, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { deviceGatewayUrl } from '../common/DeviceBridgeSession';
|
||||
import { DeviceRootEntries } from '../components/DeviceRootEntries';
|
||||
import { DeviceLocalPane, DeviceCapsPane, DeviceGatewayPane, DeviceListPane } from '../components/DevicePanes';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { parseOnlineDevices, resolveDeviceId, SUB_CAPS, SUB_GATEWAY, SUB_LIST,
|
||||
SUB_LOCAL, SUB_NONE } from '../common/DeviceModel';
|
||||
|
||||
/**
|
||||
* 设备页:只做"页面壳"。
|
||||
*
|
||||
* 拆分后的分工(拆分前这里是 560 行的单文件):
|
||||
* - 一级入口列表(本机/通道两组) → components/DeviceRootEntries.ets
|
||||
* - 四个二级页面内容(本机/能力/通道/列表)→ components/DevicePanes.ets
|
||||
* - device_id 兜底与在线设备解析 → common/DeviceModel.ets(路由 id 也在那)
|
||||
* 本文件保留:导航栈与二级页分发、授权开关、toast、设备列表拉取与生命周期。
|
||||
*/
|
||||
@Component
|
||||
export struct DevicePage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp('navVisible') private navVisible: boolean = true;
|
||||
@StorageProp('currentTab') private currentTab: number = 0;
|
||||
/** 宽屏:左边一级界面(含底部导航栏),右边二级界面 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
/** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */
|
||||
@State activeSub: string = SUB_NONE;
|
||||
@StorageProp('deviceBridgeConnected') private bridgeConnected: boolean = false;
|
||||
@State bridgeUrl: string = '';
|
||||
@State bridgeToken: string = '';
|
||||
@State deviceId: string = '';
|
||||
@State authorized: boolean = false;
|
||||
@State devices: DeviceInfo[] = [];
|
||||
@State loadingDevices: boolean = false;
|
||||
@State toastMsg: string = '';
|
||||
@State toastIsError: boolean = false;
|
||||
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
|
||||
private navStack: NavPathStack = new NavPathStack();
|
||||
|
||||
aboutToAppear(): void {
|
||||
// id 判定顺序:桥里的 id 优先于持久化的 id,都没有才生成并落盘
|
||||
this.deviceId = resolveDeviceId(deviceBridge.getDeviceId());
|
||||
this.authorized = connStore.getDeviceAuth();
|
||||
// Gateway URL derives from current connection
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
this.bridgeUrl = deviceGatewayUrl(cur.url);
|
||||
this.bridgeToken = cur.apiKey;
|
||||
}
|
||||
this.refreshDevices();
|
||||
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
|
||||
registerNavStack(2, this.navStack, () => {
|
||||
this.activeSub = SUB_NONE;
|
||||
});
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
unregisterNavStack(2);
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开二级页面。
|
||||
*
|
||||
* 窄屏(Stack 模式):push 一层,整屏覆盖,系统侧滑返回可退。
|
||||
* 宽屏(Split 模式):左栏一级列表常驻,右栏只应有一页,
|
||||
* 所以用 replace 换页而不是叠栈 —— 否则返回手势要一层层退回去。
|
||||
*/
|
||||
private openSub(id: string): void {
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.activeSub = id;
|
||||
});
|
||||
if (this.isWide && this.navStack.size() > 0) {
|
||||
this.navStack.replacePathByName(id, subPageParam(id), false);
|
||||
} else {
|
||||
this.navStack.pushPathByName(id, subPageParam(id), true);
|
||||
}
|
||||
}
|
||||
|
||||
/** 二级页面返回键:宽屏下左栏常驻可见,SubPageLayer 已隐藏返回键 */
|
||||
private closeSub(): void {
|
||||
this.navStack.pop();
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.activeSub = SUB_NONE;
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle local authorization flag; hello 同步到网关。 */
|
||||
private toggleAuthorized(on: boolean): void {
|
||||
this.authorized = on;
|
||||
try {
|
||||
connStore.saveDeviceAuth(on);
|
||||
} catch (e) {
|
||||
// ignore persist failure
|
||||
}
|
||||
deviceBridge.updateAuthorized(on);
|
||||
this.showToast(on ? '已授权,agent 可下发能力命令' : '已取消授权', false);
|
||||
}
|
||||
|
||||
private showToast(msg: string, isError: boolean): void {
|
||||
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
|
||||
this.toastIsError = isError;
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.toastMsg = msg;
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.toastMsg = '';
|
||||
});
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
/** 拉取网关在线设备列表(经 webui 反代)。 */
|
||||
private async refreshDevices(): Promise<void> {
|
||||
this.loadingDevices = true;
|
||||
try {
|
||||
// apiClient 已自动前置 /api/v1,这里只写其后的部分,
|
||||
// 否则会拼成 /api/v1/api/v1/device/online 并 404。
|
||||
const resp = await apiClient.get('/device/online');
|
||||
if (resp.status >= 200 && resp.status < 300) {
|
||||
const parsed: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const list: DeviceInfo[] = parseOnlineDevices(parsed);
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.devices = list;
|
||||
});
|
||||
} else {
|
||||
this.devices = [];
|
||||
}
|
||||
} catch (e) {
|
||||
// 网络失败保持静默,不打扰用户
|
||||
}
|
||||
this.loadingDevices = false;
|
||||
}
|
||||
|
||||
build() {
|
||||
// Navigation 提供真实导航栈:系统侧滑返回 / 三键返回都会自动 pop
|
||||
Navigation(this.navStack) {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
DeviceRootEntries({
|
||||
activeSub: this.activeSub,
|
||||
deviceId: this.deviceId,
|
||||
bridgeConnected: this.bridgeConnected,
|
||||
bridgeUrl: this.bridgeUrl,
|
||||
loadingDevices: this.loadingDevices,
|
||||
deviceCount: this.devices.length,
|
||||
onOpen: (id: string) => {
|
||||
this.openSub(id);
|
||||
},
|
||||
})
|
||||
}
|
||||
.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: 2 }) {
|
||||
NavFloatRow() {
|
||||
FloatIconButton({
|
||||
icon: $r('app.media.ic_refresh'),
|
||||
onTap: () => {
|
||||
this.refreshDevices();
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.Toast()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
.navDestination(this.SubDestination)
|
||||
// 宽屏(>=600vp)用 Split:navBar(一级列表 + 底部悬浮导航栏)常驻左栏,
|
||||
// NavDestination(二级页面)渲染在右栏,两栏同时可见。
|
||||
.mode(this.isWide ? NavigationMode.Split : NavigationMode.Stack)
|
||||
.navBarPosition(NavBarPosition.Start)
|
||||
.navBarWidth(WIDE_NAV_BAR_WIDTH)
|
||||
.minContentWidth(WIDE_MIN_CONTENT)
|
||||
.hideTitleBar(true)
|
||||
.hideToolBar(true)
|
||||
.hideBackButton(true)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
// Split 模式下 navBar 常驻,此回调不再触发;宽屏一律锁住主 Tab 横滑
|
||||
.onNavBarStateChange((isVisible: boolean) => {
|
||||
markSubPageOpen(this.isWide || !isVisible);
|
||||
})
|
||||
// 模式初始化与切换:Split 右栏不能空白,自动填入默认二级页面;
|
||||
// 退回 Stack 时清栈,否则会残留一个整屏覆盖的二级页面。
|
||||
.onNavigationModeChange((mode: NavigationMode) => {
|
||||
if (mode === NavigationMode.Split) {
|
||||
markSubPageOpen(true);
|
||||
if (this.navStack.size() === 0) {
|
||||
this.openSub(SUB_LOCAL);
|
||||
}
|
||||
} else {
|
||||
this.navStack.clear(false);
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.activeSub = SUB_NONE;
|
||||
});
|
||||
markSubPageOpen(false);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级页面路由表。
|
||||
* 每个面板自带 SubPageLayer 外壳(标题/所属 Tab/返回/刷新),这里只做分发。
|
||||
*/
|
||||
@Builder
|
||||
SubDestination(name: string, param: object) {
|
||||
NavDestination() {
|
||||
if (name === SUB_LOCAL) {
|
||||
DeviceLocalPane({
|
||||
deviceId: this.deviceId,
|
||||
authorized: this.authorized,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
onToggleAuth: (on: boolean) => {
|
||||
this.toggleAuthorized(on);
|
||||
},
|
||||
})
|
||||
} else if (name === SUB_CAPS) {
|
||||
DeviceCapsPane({
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
})
|
||||
} else if (name === SUB_GATEWAY) {
|
||||
DeviceGatewayPane({
|
||||
bridgeUrl: this.bridgeUrl,
|
||||
bridgeToken: this.bridgeToken,
|
||||
bridgeConnected: this.bridgeConnected,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
onRefresh: () => {
|
||||
this.refreshDevices();
|
||||
},
|
||||
})
|
||||
} else if (name === SUB_LIST) {
|
||||
DeviceListPane({
|
||||
devices: this.devices,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
onRefresh: () => {
|
||||
this.refreshDevices();
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
.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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const RADIUS_MD: number = 10;
|
||||
395
cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets
Normal file
395
cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets
Normal file
@ -0,0 +1,395 @@
|
||||
import { ChatPage } from './ChatPage';
|
||||
import { PluginsPage } from './PluginsPage';
|
||||
import { DevicePage } from './DevicePage';
|
||||
import { SettingsPage } from './SettingsPage';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { navBar } from '../common/NavBarController';
|
||||
import { handleBackPress } from '../common/NavStackRegistry';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
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 { MotionBase } from '../components/MotionBase';
|
||||
import { GradientBackground } from '../components/GradientBackground';
|
||||
import { ScreensuePage } from '../components/ScreensuePage';
|
||||
import { registerScreensueHandler } from '../common/BridgeRouter';
|
||||
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
|
||||
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
|
||||
import { window, display } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
interface TabItem {
|
||||
icon: Resource;
|
||||
label: string;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部导航单项:矢量图标 + 文字。
|
||||
* active 必须 @Prop 才能在父组件 currentTab 变化时刷新高亮。
|
||||
*/
|
||||
@Component
|
||||
export struct RailTab {
|
||||
icon: Resource = $r('app.media.ic_tab_chat');
|
||||
@Prop label: string = '';
|
||||
@Prop active: boolean = false;
|
||||
@Prop accent: string = '';
|
||||
@Prop muted: string = '';
|
||||
|
||||
build() {
|
||||
// 底部导航按压反馈:交给 MotionBase 统一(flexWeight 由外层调用点的
|
||||
// .layoutWeight(1) 负责,这里内部只需撑满自己那一格)。
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Column({ space: 3 }) {
|
||||
Image(this.icon)
|
||||
.width(22)
|
||||
.height(22)
|
||||
.fillColor(this.active ? this.accent : this.muted)
|
||||
// 选中态的颜色迁移必须写在子节点上:父容器的 .animation()
|
||||
// 只驱动它自己的属性,不会下传给 Image / Text。
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.draggable(false)
|
||||
|
||||
Text(this.label)
|
||||
.fontSize(10)
|
||||
// fontWeight 不可插值,切换时仍是跳变,只对颜色做过渡
|
||||
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(this.active ? this.accent : this.muted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.maxLines(1)
|
||||
}
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.width('100%')
|
||||
.height(48)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct Index {
|
||||
@Watch('onTabChanged') @State currentTab: number = 0;
|
||||
@StorageProp('navVisible') private navVisible: boolean = true;
|
||||
/** 二级页面打开时锁住 Swiper 横滑,避免误切换主 Tab */
|
||||
@StorageProp('subPageOpen') private subPageOpen: boolean = false;
|
||||
/** 宽屏(>=600vp):一级界面在左、二级界面在右,导航栏跟随左侧一级界面 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
@State screensueVisible: boolean = false;
|
||||
@State screensueText: string = '';
|
||||
@State screensueCountdown: number = 0;
|
||||
/** 状态栏避让高度(vp),从窗口 avoid area 动态获取;默认 44 兜底 */
|
||||
@State topInset: number = 44;
|
||||
/** 底部手势条高度(vp) */
|
||||
@State bottomGesture: number = 16;
|
||||
@StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true;
|
||||
private swiper: SwiperController = new SwiperController();
|
||||
private screensueTimer: number = -1;
|
||||
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
|
||||
|
||||
// 「状态」Tab 已删除:运行状态并入设置页(顶部摘要卡 + 二级明细页),
|
||||
// 底部导航只保留四项,胶囊内每项更宽、点按更好命中。
|
||||
private tabs: TabItem[] = [
|
||||
{ icon: $r('app.media.ic_tab_chat'), label: '聊天', pageId: 'chat' },
|
||||
{ icon: $r('app.media.ic_tab_plugins'), label: '插件', pageId: 'plugins' },
|
||||
{ icon: $r('app.media.ic_tab_devices'), label: '设备', pageId: 'devices' },
|
||||
{ icon: $r('app.media.ic_tab_settings'), label: '设置', pageId: 'settings' },
|
||||
];
|
||||
|
||||
aboutToAppear(): void {
|
||||
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
// 种子化自定义背景图状态到 AppStorage,GradientBackground 响应读取
|
||||
const st = connStore.getSettings();
|
||||
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
|
||||
AppStorage.setOrCreate<number>('bgOpacity', st.bgOpacity ?? 0.25);
|
||||
AppStorage.setOrCreate<boolean>('navVisible', true);
|
||||
AppStorage.setOrCreate<boolean>('subPageOpen', false);
|
||||
AppStorage.setOrCreate<boolean>('isWideScreen', false);
|
||||
// 启动时按当前主题刷新一次系统栏(EntryAbility 已设过,这里兜底对齐)
|
||||
this.syncSystemBar();
|
||||
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44)
|
||||
this.resolveSafeArea();
|
||||
// 根 UI 只负责 screensue 呈现;命令路由由前台全局设备桥安装。
|
||||
registerScreensueHandler((payload: ScreensuePayload) => {
|
||||
this.showScreensue(payload);
|
||||
});
|
||||
markForegroundBridgeUIReady(getContext(this) as common.UIAbilityContext);
|
||||
}
|
||||
|
||||
/** agent 下发的 screensue 内容展示(窄屏整页、宽屏右栏;0=常驻)。 */
|
||||
private showScreensue(payload: ScreensuePayload): void {
|
||||
this.screensueText = payload.content;
|
||||
this.screensueCountdown = payload.duration;
|
||||
this.screensueVisible = true;
|
||||
if (this.screensueTimer >= 0) {
|
||||
clearInterval(this.screensueTimer);
|
||||
this.screensueTimer = -1;
|
||||
}
|
||||
if (payload.duration > 0) {
|
||||
this.screensueTimer = setInterval(() => {
|
||||
if (this.screensueCountdown <= 1) {
|
||||
clearInterval(this.screensueTimer);
|
||||
this.screensueTimer = -1;
|
||||
this.screensueVisible = false;
|
||||
} else {
|
||||
this.screensueCountdown = this.screensueCountdown - 1;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private closeScreensue(): void {
|
||||
if (this.screensueTimer >= 0) {
|
||||
clearInterval(this.screensueTimer);
|
||||
this.screensueTimer = -1;
|
||||
}
|
||||
this.screensueVisible = false;
|
||||
}
|
||||
|
||||
/** 主题翻转时同步系统栏前景/背景色,避免浅色页面配黑状态栏。 */
|
||||
private onThemeChanged(): void {
|
||||
this.syncSystemBar();
|
||||
}
|
||||
|
||||
/** 通过窗口 avoid area 计算状态栏与底部手势区高度(vp),失败时保留默认值。 */
|
||||
private resolveSafeArea(): void {
|
||||
try {
|
||||
const ctx = getContext(this) as common.UIAbilityContext;
|
||||
window.getLastWindow(ctx).then((win: window.Window) => {
|
||||
const prop = win.getWindowProperties();
|
||||
const isLayoutFull = prop.isLayoutFullScreen === true;
|
||||
if (!isLayoutFull) {
|
||||
return; // 非全屏模式下系统自动避让
|
||||
}
|
||||
const density = display.getDefaultDisplaySync().densityPixels > 0
|
||||
? display.getDefaultDisplaySync().densityPixels : 3;
|
||||
const topArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
|
||||
const bottomArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
|
||||
if (topArea.topRect.height > 0) {
|
||||
this.topInset = Math.ceil(topArea.topRect.height / density);
|
||||
}
|
||||
// 底部手势条:有值时取其高度的一半作为悬浮导航的下边距基准,最小 8 最大 24
|
||||
if (bottomArea.bottomRect.height > 0) {
|
||||
const h: number = Math.ceil(bottomArea.bottomRect.height / density);
|
||||
this.bottomGesture = Math.min(24, Math.max(8, h));
|
||||
} else {
|
||||
this.bottomGesture = 16;
|
||||
}
|
||||
}).catch(() => {
|
||||
// ignore, keep defaults
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
AppStorage.set<number>('currentTab', 0);
|
||||
}
|
||||
|
||||
private onTabChanged(): void {
|
||||
AppStorage.set<number>('currentTab', this.currentTab);
|
||||
}
|
||||
|
||||
private syncSystemBar(): void {
|
||||
const dark: boolean = this.isDark;
|
||||
const bg: string = dark ? '#000000' : '#F1F3F5';
|
||||
const fg: string = dark ? '#FFFFFF' : '#191919';
|
||||
try {
|
||||
window.getLastWindow(getContext(this) as common.UIAbilityContext).then((win: window.Window) => {
|
||||
win.setWindowSystemBarProperties({
|
||||
statusBarColor: bg,
|
||||
statusBarContentColor: fg,
|
||||
navigationBarColor: bg,
|
||||
});
|
||||
win.setWindowBackgroundColor(bg);
|
||||
}).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/** 宽屏判定:窗口宽度 >= 600vp(与 Navigation Auto 模式的分栏阈值一致) */
|
||||
private updateWideScreen(w: number): void {
|
||||
const wide: boolean = w >= WIDE_MIN_WIDTH;
|
||||
if (wide !== this.isWide) {
|
||||
AppStorage.set<boolean>('isWideScreen', wide);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的返回处理:把返回事件精确派发给【当前 Tab】的那个 Navigation 栈。
|
||||
*
|
||||
* 四个页面各自持有 Navigation(宽屏需要"左一级右二级"),它们在 Swiper 里
|
||||
* 同时存活,系统返回落到哪个栈并不确定 —— 这就是"有的页面无法使用返回手势"
|
||||
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
|
||||
*/
|
||||
onBackPress(): boolean {
|
||||
if (this.screensueVisible) {
|
||||
this.closeScreensue();
|
||||
return true;
|
||||
}
|
||||
return handleBackPress(this.currentTab, this.isWide);
|
||||
}
|
||||
|
||||
build() {
|
||||
// alignContent Bottom:悬浮导航停靠底部,而不是被 Stack 居中。
|
||||
// 导航胶囊用左右 6% padding 保持"宽度占 88% 居中"的原样视觉;
|
||||
// 宽屏下胶囊自然被限制在左侧一级界面栏内,不会横跨二级界面。
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
// 渐变氛围背景
|
||||
GradientBackground()
|
||||
|
||||
// 页面内容:Swiper 支持左右滑动切换;$$ 双向同步索引
|
||||
Swiper(this.swiper) {
|
||||
ChatPage()
|
||||
PluginsPage()
|
||||
DevicePage()
|
||||
SettingsPage()
|
||||
}
|
||||
.index(this.currentTab)
|
||||
.indicator(false)
|
||||
.loop(false)
|
||||
.duration(300)
|
||||
.curve(Curve.FastOutSlowIn)
|
||||
.vertical(false)
|
||||
.cachedCount(2)
|
||||
.disableSwipe(this.subPageOpen)
|
||||
.onChange((idx: number) => {
|
||||
this.currentTab = idx;
|
||||
navBar.setVisible(true);
|
||||
})
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
// top 让出状态栏;bottom:所有页面统一为0,导航栏浮在内容之上
|
||||
.padding({
|
||||
top: this.topInset,
|
||||
bottom: 0
|
||||
})
|
||||
|
||||
// 悬浮气态玻璃底部导航(三层:半透明底 -> 高光渐变 -> 内容)
|
||||
// 外层 Row 负责水平定位:窄屏居中;宽屏靠左,只落在一级界面栏范围内。
|
||||
Row() {
|
||||
Stack() {
|
||||
// 层1:玻璃底 —— 半透明背景(无 backdropBlur 以避免矩形模糊伪影)
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
|
||||
// 层2:玻璃高光渐变,模拟光从上方照射的质感
|
||||
Row()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.linearGradient({
|
||||
direction: GradientDirection.Top,
|
||||
colors: [
|
||||
[this.palette().navBarGradientStart, 0.0],
|
||||
[this.palette().navBarGradientEnd, 1.0],
|
||||
],
|
||||
})
|
||||
|
||||
// 层3:导航项
|
||||
Row({ space: 2 }) {
|
||||
ForEach(this.tabs, (tab: TabItem, index: number) => {
|
||||
RailTab({
|
||||
icon: tab.icon,
|
||||
label: tab.label,
|
||||
active: this.currentTab === index,
|
||||
accent: this.palette().accent,
|
||||
muted: this.palette().textMuted,
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.onClick(() => {
|
||||
this.currentTab = index;
|
||||
navBar.setVisible(true);
|
||||
})
|
||||
}, (item: TabItem, index: number) => item.pageId + index.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.height(62)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
// 宽屏:胶囊宽度锁在左侧一级界面栏内(左右各留 24vp 余量)
|
||||
.width(this.isWide ? WIDE_NAV_BAR_WIDTH - 48 : '88%')
|
||||
.height(62)
|
||||
.borderRadius(28)
|
||||
.clip(true)
|
||||
.border({
|
||||
width: { left: 1, top: 1, right: 1, bottom: 1 },
|
||||
color: this.palette().navBarBorder,
|
||||
})
|
||||
.shadow({
|
||||
radius: 24,
|
||||
color: this.palette().shadow,
|
||||
offsetY: 8,
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height(62)
|
||||
.padding({ left: this.isWide ? 24 : 0 })
|
||||
.justifyContent(this.isWide ? FlexAlign.Start : FlexAlign.Center)
|
||||
.margin({ bottom: this.bottomGesture + 6 })
|
||||
// 所有页面滑动时隐藏导航栏
|
||||
.translate({ y: !this.navVisible ? 130 : 0 })
|
||||
.opacity(!this.navVisible ? 0 : 1)
|
||||
.animation({ duration: ANIM_SLOW, curve: Curve.EaseOut })
|
||||
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
|
||||
// screensue 是前台内容页:窄屏覆盖整页;宽屏仅覆盖右侧内容栏,
|
||||
// 左侧一级页面与主导航保持可见、可操作。
|
||||
if (this.screensueVisible) {
|
||||
if (this.isWide) {
|
||||
Row() {
|
||||
Column()
|
||||
.width(WIDE_NAV_BAR_WIDTH)
|
||||
.height('100%')
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
|
||||
ScreensuePage({
|
||||
pushedText: this.screensueText,
|
||||
countdown: this.screensueCountdown,
|
||||
onClose: () => {
|
||||
this.closeScreensue();
|
||||
},
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.height('100%')
|
||||
.padding({ top: this.topInset })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
} else {
|
||||
ScreensuePage({
|
||||
pushedText: this.screensueText,
|
||||
countdown: this.screensueCountdown,
|
||||
onClose: () => {
|
||||
this.closeScreensue();
|
||||
},
|
||||
})
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ top: this.topInset })
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.id(snapshotComponentId())
|
||||
// 实时跟随窗口宽度:平板旋转、折叠展开、分屏、自由窗口拖拽都会回调,
|
||||
// 比启动时查一次 display 更可靠(不会漏掉运行中的尺寸变化)。
|
||||
.onAreaChange((oldValue: Area, newValue: Area) => {
|
||||
this.updateWideScreen(newValue.width as number);
|
||||
})
|
||||
}
|
||||
}
|
||||
374
cmd/ohos/HomeAgent/entry/src/main/ets/pages/PluginsPage.ets
Normal file
374
cmd/ohos/HomeAgent/entry/src/main/ets/pages/PluginsPage.ets
Normal file
@ -0,0 +1,374 @@
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage } from '../common/UserError';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
||||
import { ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { PluginDetailPane } from '../components/PluginDetailPane';
|
||||
import { PluginListView } from '../components/PluginListView';
|
||||
import { PluginInstallForm } from '../components/PluginsOverlays';
|
||||
import { ToastBar } from '../components/ToastBar';
|
||||
import { fetchPluginRows, fetchPluginDetail } from '../common/PluginApi';
|
||||
|
||||
/** 二级页面标识:插件详情 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_DETAIL: string = 'detail';
|
||||
|
||||
interface InstallBody {
|
||||
url: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct PluginsPage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp('navVisible') private navVisible: boolean = true;
|
||||
@StorageProp('currentTab') private currentTab: number = 0;
|
||||
/** 宽屏:左边插件列表(含底部导航栏),右边插件详情 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
@State plugins: PluginRow[] = [];
|
||||
@State loading: boolean = false;
|
||||
@State showInstallForm: boolean = false;
|
||||
@State installUrl: string = '';
|
||||
@State toastMsg: string = '';
|
||||
@State toastIsError: boolean = false;
|
||||
/** 当前查看详情的插件名,用于宽屏下高亮左侧列表项 */
|
||||
@State activeName: string = '';
|
||||
/** 详情页数据(GET /plugins/{name}) */
|
||||
@State detail: PluginDetail = emptyPluginDetail();
|
||||
@State detailLoading: boolean = false;
|
||||
@State detailError: string = '';
|
||||
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
|
||||
private navStack: NavPathStack = new NavPathStack();
|
||||
|
||||
aboutToAppear(): void {
|
||||
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
|
||||
registerNavStack(1, this.navStack, () => {
|
||||
this.activeName = '';
|
||||
});
|
||||
this.loadPlugins();
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
unregisterNavStack(1);
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/** 取数口径见 common/PluginApi.ets;这里只负责把结果落到 @State。 */
|
||||
private async loadPlugins(): Promise<void> {
|
||||
if (!apiClient.hasConnection()) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const rows: PluginRow[] = await fetchPluginRows();
|
||||
// 列表整体重建(ForEach key 含 loaded/disabled,启停会整行重挂载):
|
||||
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.plugins = rows;
|
||||
});
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.load', e), true);
|
||||
}
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
private async togglePlugin(plugin: PluginRow): Promise<void> {
|
||||
const name: string = plugin.name;
|
||||
const action: string = plugin.disabled ? 'enable' : 'disable';
|
||||
try {
|
||||
await apiClient.post('/plugins/' + name + '/' + action, null);
|
||||
this.showToast('已' + (plugin.disabled ? '启用' : '禁用') + '插件: ' + name, false);
|
||||
this.loadPlugins();
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.toggle', e), true);
|
||||
}
|
||||
}
|
||||
|
||||
private async removePlugin(plugin: PluginRow): Promise<void> {
|
||||
const name: string = plugin.name;
|
||||
try {
|
||||
await apiClient.request('/plugins/' + name, 'DELETE', '', 8000);
|
||||
this.showToast('已卸载插件: ' + name, false);
|
||||
this.loadPlugins();
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.remove', e), true);
|
||||
}
|
||||
}
|
||||
|
||||
private async installPlugin(): Promise<void> {
|
||||
const url: string = this.installUrl.trim();
|
||||
if (url.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const bodyObj: InstallBody = { url: url };
|
||||
// 后端没有 /plugins/install 这个路由:安装就是 POST /plugins,body 带 url。
|
||||
await apiClient.post('/plugins', bodyObj);
|
||||
this.showToast('安装请求已发送', false);
|
||||
this.installUrl = '';
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.showInstallForm = false;
|
||||
});
|
||||
this.loadPlugins();
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.install', e), true);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 二级页面:插件详情 =====================
|
||||
|
||||
/**
|
||||
* 打开插件详情。
|
||||
*
|
||||
* 窄屏(Stack):push 一层整屏覆盖,系统侧滑返回可退。
|
||||
* 宽屏(Split):左栏列表常驻,右栏换页而不叠栈。
|
||||
*/
|
||||
private openDetail(name: string): void {
|
||||
this.activeName = name;
|
||||
this.loadDetail(name);
|
||||
if (this.isWide && this.navStack.size() > 0) {
|
||||
this.navStack.replacePathByName(SUB_DETAIL, subPageParam(SUB_DETAIL), false);
|
||||
} else {
|
||||
this.navStack.pushPathByName(SUB_DETAIL, subPageParam(SUB_DETAIL), true);
|
||||
}
|
||||
}
|
||||
|
||||
private closeDetail(): void {
|
||||
this.navStack.pop();
|
||||
this.activeName = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情数据落地。
|
||||
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
|
||||
*/
|
||||
private async loadDetail(name: string): Promise<void> {
|
||||
this.detailLoading = true;
|
||||
this.detailError = '';
|
||||
try {
|
||||
const d: PluginDetail = await fetchPluginDetail(name);
|
||||
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.detail = d;
|
||||
});
|
||||
} catch (e) {
|
||||
// 内置插件没有清单,属于预期情况,不当成错误刷红
|
||||
const row: PluginRow | null = this.findRow(name);
|
||||
const d: PluginDetail = emptyPluginDetail();
|
||||
d.name = name;
|
||||
if (row !== null) {
|
||||
d.version = row.version ?? '';
|
||||
d.description = row.description ?? '';
|
||||
}
|
||||
const errText: string = (row !== null && row.external) ? userMessage('plugins.detail', e) : '';
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.detail = d;
|
||||
this.detailError = errText;
|
||||
});
|
||||
}
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.detailLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
private findRow(name: string): PluginRow | null {
|
||||
for (let i = 0; i < this.plugins.length; i++) {
|
||||
if (this.plugins[i].name === name) {
|
||||
return this.plugins[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 详情页正在展示的那一行(供二级页面里的操作按钮使用) */
|
||||
private activeRow(): PluginRow | null {
|
||||
return this.findRow(this.activeName);
|
||||
}
|
||||
|
||||
private async reloadPlugins(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/plugins/reload', null);
|
||||
this.showToast('插件已重载', false);
|
||||
this.loadPlugins();
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.reload', e), true);
|
||||
}
|
||||
}
|
||||
|
||||
private showToast(msg: string, isError: boolean): void {
|
||||
// 颜色标志必须在 animateTo 闭包之外先落地,否则第一帧配色是上一次的
|
||||
this.toastIsError = isError;
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.toastMsg = msg;
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.toastMsg = '';
|
||||
});
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
build() {
|
||||
// 宽屏(>=600vp)走 Split:navBar(左栏 = 插件列表 + 底部导航栏)常驻,
|
||||
// NavDestination(右栏 = 插件详情)与之并列;窄屏则是整屏覆盖的二级页面。
|
||||
Navigation(this.navStack) {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
PluginListView({
|
||||
plugins: this.plugins,
|
||||
busy: this.loading,
|
||||
activeName: this.activeName,
|
||||
hasConn: apiClient.hasConnection(),
|
||||
onSelect: (name: string) => {
|
||||
this.openDetail(name);
|
||||
},
|
||||
})
|
||||
}
|
||||
.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%')
|
||||
|
||||
// 顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None,触摸完全穿透到滚动区)
|
||||
PageTopBar({ title: '插件' })
|
||||
|
||||
// 悬浮操作区:安装(悬浮主按钮)+ 重载。
|
||||
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
|
||||
NavFloatOverlay({ tab: 1 }) {
|
||||
if (this.showInstallForm) {
|
||||
PluginInstallForm({
|
||||
url: this.installUrl,
|
||||
onUrlChange: (v: string) => {
|
||||
this.installUrl = v;
|
||||
},
|
||||
onInstall: () => {
|
||||
this.installPlugin();
|
||||
},
|
||||
})
|
||||
}
|
||||
NavFloatRow() {
|
||||
FloatIconButton({
|
||||
icon: $r('app.media.ic_reload'),
|
||||
onTap: () => {
|
||||
this.reloadPlugins();
|
||||
},
|
||||
})
|
||||
Button() {
|
||||
Image($r('app.media.ic_plus'))
|
||||
.width(20)
|
||||
.height(20)
|
||||
.fillColor(Color.White)
|
||||
.rotate({ angle: this.showInstallForm ? 45 : 0 })
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
}
|
||||
.width(50)
|
||||
.height(50)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
|
||||
.onClick(() => {
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.showInstallForm = !this.showInstallForm;
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ToastBar({ msg: this.toastMsg, isError: this.toastIsError, bordered: true })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
.navDestination(this.SubDestination)
|
||||
.mode(this.isWide ? NavigationMode.Split : NavigationMode.Stack)
|
||||
.navBarPosition(NavBarPosition.Start)
|
||||
.navBarWidth(WIDE_NAV_BAR_WIDTH)
|
||||
.minContentWidth(WIDE_MIN_CONTENT)
|
||||
.hideTitleBar(true)
|
||||
.hideToolBar(true)
|
||||
.hideBackButton(true)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
// Split 模式下 navBar 常驻,此回调不再触发;宽屏一律锁住主 Tab 横滑
|
||||
.onNavBarStateChange((isVisible: boolean) => {
|
||||
markSubPageOpen(this.isWide || !isVisible);
|
||||
})
|
||||
// 模式初始化与切换:Split 右栏不能空白,自动选中第一个插件;
|
||||
// 退回 Stack 时清栈,否则会残留一个整屏覆盖的详情页。
|
||||
.onNavigationModeChange((mode: NavigationMode) => {
|
||||
if (mode === NavigationMode.Split) {
|
||||
markSubPageOpen(true);
|
||||
if (this.navStack.size() === 0 && this.plugins.length > 0) {
|
||||
this.openDetail(this.plugins[0].name);
|
||||
}
|
||||
} else {
|
||||
this.navStack.clear(false);
|
||||
this.activeName = '';
|
||||
markSubPageOpen(false);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 二级页面路由表:插件详情 */
|
||||
@Builder
|
||||
SubDestination(name: string, param: object) {
|
||||
NavDestination() {
|
||||
if (name === SUB_DETAIL) {
|
||||
SubPageLayer({
|
||||
title: this.activeName.length > 0 ? this.activeName : '插件详情',
|
||||
tab: 1,
|
||||
onBack: () => {
|
||||
this.closeDetail();
|
||||
},
|
||||
showRefresh: true,
|
||||
onRefresh: () => {
|
||||
this.loadDetail(this.activeName);
|
||||
},
|
||||
}) {
|
||||
PluginDetailPane({
|
||||
detail: this.detail,
|
||||
activeName: this.activeName,
|
||||
busy: this.detailLoading,
|
||||
errorText: this.detailError,
|
||||
row: this.activeRow() ?? undefined,
|
||||
onToggle: () => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
this.togglePlugin(r);
|
||||
}
|
||||
},
|
||||
onRemove: () => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
this.removePlugin(r);
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user