Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 579d7dbaee | |||
| 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 |
25
.gitignore
vendored
@ -10,7 +10,6 @@ data/
|
||||
.tmp-plugins/
|
||||
.go/
|
||||
.local/
|
||||
internal/meta/
|
||||
|
||||
*.hmap
|
||||
dev/
|
||||
@ -24,6 +23,11 @@ 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/
|
||||
@ -36,12 +40,23 @@ 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/
|
||||
terminal_locked_log.txt
|
||||
|
||||
dist/
|
||||
.pi-glla/
|
||||
.omo/
|
||||
.pi/
|
||||
|
||||
# HarmonyOS 构建工具链缓存(HVIGOR_USER_HOME / ohpm 生成,非源码)
|
||||
.hvigor-home/
|
||||
.npm-cache/
|
||||
.ohpm/
|
||||
|
||||
46
README.md
@ -1,3 +1,5 @@
|
||||
> ⚠️ **AI 辅助编程声明**:本项目代码、文档及提交历史中,部分内容由 AI 辅助生成或修改。人工已审阅关键改动,但使用时请自行评估与验证。
|
||||
|
||||
# HomeAgent
|
||||
|
||||
> **English**: [README_EN.md](./README_EN.md)
|
||||
@ -10,6 +12,8 @@
|
||||
homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
||||
```
|
||||
|
||||
**v1.0.0 起外部插件是独立子进程**:经 stdio JSON-RPC(控制面)+ 共享内存段(数据面)+ 事件环(通知面)与内核通信。插件崩溃不影响内核且自动重启,换 `plugin.bin` 即生效的真热重载。
|
||||
|
||||
## 设计要点
|
||||
|
||||
**核心域与应用域分离** — 内核职责限定为 LLM 编排、记忆管理与知识检索;所有 IO 能力(消息收发、文件读写、网络请求、硬件交互等)由插件实现。这种划分在 Agent 框架层面进行领域边界界定,内核与插件各有其责任范围。
|
||||
@ -149,6 +153,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,7 +182,7 @@ 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 配置中心
|
||||
@ -172,7 +193,11 @@ internal/
|
||||
|
||||
## 项目状态
|
||||
|
||||
**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.1.0** — 记忆系统支持二进制多媒体节点。此前四层记忆(L0 活跃上下文 / L1 文本 / L2 文档 / L3 图库)全部只存文字,图片音频经视觉模型转成描述后原始字节即丢弃,"那张紫蓝红三色带图"再也取不回来。本版新增内容寻址媒体存储(CAS,`internal/memory/media`):元数据进 SQLite、blob 按 sha256 落盘去重,L0/L2/L3 各层只记 digest 并通过 `media_refs` 维护引用计数,容量上限由后台 GC 真正兑现(被引用的内容即便超限也永不删除)。**描述文本才是持久语义记忆,blob 只是缓存**——描述随记忆各层一直留存并可检索,原始字节可被容量 GC 淘汰,因此几个月后仍能从图库句子反查到那张图(若尚在则逐字节取回)。描述由后台循环经视觉源生成(默认关闭,开启后每 30s 最多 4 条,不与对话抢配额),放在对话路径上会给每张图的回复加十几秒而收益为零——那一轮模型本来就直接看着图。同时修五个缺陷:`core.New` 漏接 `rc.SetMediaStore` 致 L0→L2 引用转移在生产静默失效;三元组全被实体名校验拒绝时仍释放引用并删除文档(数据丢失,已反向验证);媒体入图库曾依赖 NLP 提取器碰巧提出合规三元组而时好时坏,改为按媒体标记确定性产出;L3 媒体检索一度没有任何调用方(能存进去、agent 拿不出来);`remotedevice` 网关与 `agentcli` 终端各一处数据竞争。配套 `-tags medialive` 自动触发链实测:只注入一个图片事件,落盘/描述/归档/图库绑定/GC 保护/二轮召回七个阶段全由生产代码自行触发,真实视觉模型下 agent 在不给图的第二轮准确答出三条色带的颜色与近似 hex。插件 ABI 未变(`SDKCompatibleVersion` 仍为 1.0.0),存量 `plugin.bin` 无需重编。
|
||||
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
|
||||
**v0.9.0** — C ABI v2:外部插件 Stage 回调支持写回(`invoke_stage` 增加 result 输出,插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核),ABI 版本随内核 minor 对齐(v0.9.x → ABIVersion=2,`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题。配套 SDK 提供增强版 sanitizer 示例(坏 UTF-8/U+FFFD/ANSI 转义全链路清洗)。**该 ABI 已随 v1.0.0 退场。**
|
||||
|
||||
**v0.8.0** — 核心可用,插件系统增强。内置 20+ 插件,外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库。新增输入通道 `NoMemory`/`Cleaner`、`ChannelDef`、插件禁用/启用系统(CLI + WebUI),`plugindev` 工具链完成 C ABI `ChannelDef` 传递。
|
||||
|
||||
@ -184,6 +209,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.1.0_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
||||
- 校验:`SHA256SUMS`
|
||||
|
||||
macOS 的 `homed` 需在原生 macOS 构建(CGO + sqlite3),发布包仅含 `waiter`/`initconfig`。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
|
||||
32
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,11 @@ Combined with a **three-layer memory architecture** (Context → Document → Gr
|
||||
homed (kernel, zero IO) ← PluginSDK → plugins (all IO capabilities)
|
||||
```
|
||||
|
||||
**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,7 +168,7 @@ 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
|
||||
@ -172,7 +179,11 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
||||
|
||||
## 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.1.0** — Binary/multimedia nodes in the memory system. All four tiers (L0 active context / L1 text / L2 documents / L3 graph) previously stored text only: an image or audio clip was turned into a description by a vision model and the original bytes were dropped, so "that purple-blue-red banded image" could never be retrieved again. This release adds a content-addressed media store (CAS, `internal/memory/media`): metadata in SQLite, blobs deduplicated on disk by sha256, with every tier holding only digests and reference counts maintained through `media_refs`, so the capacity cap is finally enforced by a background GC (referenced content is never deleted, even over the limit). **The description text is the durable semantic memory; the blob is only a cache** — descriptions persist across all tiers and stay searchable while raw bytes may be evicted, so months later a graph sentence still resolves back to that image (byte-for-byte if it survives). Descriptions are generated by a background loop through a vision source (off by default; at most 4 items per 30s when enabled, so it never competes with conversations for quota) — doing it inline would add tens of seconds to every image reply for no gain, since the model is looking at the image in that turn anyway. Five defects fixed as well: `core.New` never called `rc.SetMediaStore`, silently disabling L0→L2 reference transfer in production; references were released and the document deleted even when every triple was rejected by entity-name validation (data loss, reverse-verified); media entering the graph depended on the NLP extractor happening to produce valid triples and was therefore intermittent, now replaced by deterministic triples derived from media markers; L3 media lookup had no callers at all (stored fine, unreachable by the agent); and one data race each in the `remotedevice` gateway and the `agentcli` terminal. Ships with a `-tags medialive` auto-trigger integration test: a single injected image event drives all seven stages — CAS write, description, archival, graph binding, GC protection, second-turn recall — entirely through production code paths, and with a real vision model the agent names all three band colours and their approximate hex values in a second turn that includes no image. Plugin ABI unchanged (`SDKCompatibleVersion` stays 1.0.0); existing `plugin.bin` files need no rebuild.
|
||||
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
|
||||
**v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.**
|
||||
|
||||
**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 +195,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.1.0_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||
- 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
|
||||
|
||||
@ -259,12 +259,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 | `plugindev build` → `plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
|
||||
| Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. |
|
||||
| SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter |
|
||||
|
||||
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 +278,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 `plugindev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
|
||||
| Distribution | Bundled with kernel, not independently installable | `.hmap` package (ZIP archive), installed via WebUI or pluginmgr API |
|
||||
| Metadata | `plugin.RegisterPluginMeta()` for display name | `plugin.json` manifest file (name, version, entry, platforms, 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), 51 `core.*` methods | The process boundary *is* the ABI boundary—no need to maintain three platform-specific dynamic-library loaders |
|
||||
| Data | Shared memory segment, **one segment shared by all subprocesses** | One segment per plugin would degrade "kernel ctx → segment → plugin mutates → read back" into the copy model under concurrency, reproducing lost updates exactly |
|
||||
| Notification | Event ring + platform notify (Linux eventfd / macOS pipe / Windows Event) | The kernel must never block on a consumer: streaming output publishes per token, so any wait shows up as stutter |
|
||||
|
||||
**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
|
||||
|
||||
```
|
||||
|
||||
@ -50,7 +50,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,7 +29,7 @@ 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 `plugindev` 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` |
|
||||
|
||||
@ -114,7 +114,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**:
|
||||
|
||||
@ -142,8 +142,8 @@ plugindev build --replace <mod@path> # append a go.mod replace directive (repeat
|
||||
|
||||
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,13 +155,28 @@ 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 `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
|
||||
|
||||
@ -269,8 +284,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, `plugindev build` auto-generates subprocess runtime code
|
||||
(`z_proc_gen.go` for the platform-independent part, plus `z_proc_shm_unix.go` /
|
||||
`z_proc_shm_windows.go`). All three platforms share the same entry point and the same
|
||||
RPC logic; only the cross-process resource-passing mechanism differs (inherited fds on
|
||||
Unix, named kernel objects on Windows). No manual bridge code needed.
|
||||
|
||||
### PluginSDK Core API
|
||||
|
||||
@ -322,7 +340,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 +545,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,7 +594,7 @@ 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 fully aligned with external plugins (toolchain-built `plugin.bin` subprocesses): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors.
|
||||
|
||||
**Registration**
|
||||
|
||||
@ -594,7 +612,7 @@ The `sdk.*` API of Lua plugins is fully aligned with external plugins (C ABI / t
|
||||
|
||||
Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `final_text`, `no_memory`, `response` (when responded), `tool_calls`, `tool_results`.
|
||||
|
||||
**Stage writeback (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)
|
||||
@ -622,7 +640,7 @@ Writable fields: `raw_message`, `llm_text`, `final_text`, `user_id`, `group_id`,
|
||||
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
|
||||
| `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation |
|
||||
|
||||
**Data APIs (aligned with C ABI, all return `(result, err)`)**
|
||||
**Data APIs (aligned with subprocess external plugins, all return `(result, err)`)**
|
||||
|
||||
| Sub-table | Functions |
|
||||
|-----------|-----------|
|
||||
|
||||
@ -257,12 +257,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 反向注册 | `plugindev 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 +276,37 @@ 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` 二进制,无需独立编译 | 通过 `plugindev 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 帧),51 个 `core.*` method | 进程边界即 ABI 边界,无需维护三套平台特定的动态库加载代码 |
|
||||
| 数据面 | 共享内存段,**全部子进程共用一块** | 每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型,lost update 原样复现 |
|
||||
| 通知面 | 事件环 + 平台通知(Linux eventfd / macOS pipe / Windows Event) | 内核发事件绕不等消费者,流式输出逐 token 发布时任何等待都会造成卡顿 |
|
||||
|
||||
**子进程生命周期管理**:
|
||||
- 每子进程一根专职 `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 四通道
|
||||
|
||||
```
|
||||
|
||||
@ -50,7 +50,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,7 +30,7 @@ type Plugin interface {
|
||||
|
||||
| 方式 | 适用场景 | 复杂度 |
|
||||
|------|---------|--------|
|
||||
| **动态 .so/.dll 插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
|
||||
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
|
||||
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
|
||||
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 |
|
||||
|
||||
@ -115,7 +115,7 @@ myplugin/
|
||||
└── thirdpart/ — 外部源码存放目录(可选)
|
||||
```
|
||||
|
||||
编译时自动生成 C ABI bridge 文件(`z_bridge_gen.go` + `z_entry.c`),无需手动创建。
|
||||
编译时自动生成子进程运行时文件(`z_proc_gen.go` 等),无需手动创建。
|
||||
|
||||
**Lua 插件**:
|
||||
|
||||
@ -143,8 +143,8 @@ plugindev 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` + 二进制)
|
||||
@ -156,13 +156,25 @@ plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次
|
||||
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"`);`bundle` — 多平台合集开关(默认 `true`)|
|
||||
| `plugin.json` | 构建产物清单,`plugindev 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 接口零改动,只需用新版 `plugindev` 重编。
|
||||
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
|
||||
> 它现在只用于区分 Lua 插件。
|
||||
> - 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
|
||||
> - Windows 从「只下发 3 个 stage 字段、无写回」升级到 16 字段全可见 + 写回,
|
||||
> 与 Unix 共用同一套 RPC 实现。
|
||||
|
||||
### 构建目标与多平台打包(bundle)
|
||||
|
||||
@ -269,7 +281,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 共享同一入口。
|
||||
编译时 `plugindev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑,仅跨进程资源传递机制不同(Unix 继承 fd,Windows 命名内核对象)。
|
||||
|
||||
### PluginSDK 核心 API
|
||||
|
||||
@ -321,7 +333,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 +538,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,7 +587,7 @@ 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` 子进程)能力完全对齐:注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||
|
||||
**注册类**
|
||||
|
||||
@ -593,7 +605,7 @@ Lua 插件的 `sdk.*` API 与外部插件(C ABI / 工具链编译的 `.so`/`.d
|
||||
|
||||
`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message`、`user_id`、`group_id`、`phase`、`llm_text`、`final_text`、`no_memory`、`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)
|
||||
@ -621,7 +633,7 @@ end)
|
||||
| `sdk.inject_interrupt(source, channel, text)` | 中断投递 |
|
||||
| `sdk.inject_text_no_memory(source, channel, text)` | 免记忆投递 |
|
||||
|
||||
**数据类(与 C ABI 对齐,均返回 `(result, err)`)**
|
||||
**数据类(与子进程外部插件对齐,均返回 `(result, err)`)**
|
||||
|
||||
| 子表 | 函数 |
|
||||
|------|------|
|
||||
|
||||
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
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"),
|
||||
},
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -25,6 +25,7 @@ import (
|
||||
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"
|
||||
@ -295,6 +296,8 @@ func main() {
|
||||
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 != "" {
|
||||
@ -322,6 +325,27 @@ func main() {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
|
||||
// L0/L2/L3 只记 digest。开关默认开;关闭后全部媒体接线静默跳过,
|
||||
// 对话行为与本特性上线前完全一致。
|
||||
var mediaStore *media.Store
|
||||
if cfgReg.GetBool("core.memory.media.enabled", true) {
|
||||
mediaDir := cfgReg.GetString("core.memory.media.dir",
|
||||
filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
|
||||
maxMB := cfgReg.GetInt("core.memory.media.max_mb", 2048)
|
||||
ms, err := media.New(mediaDir, int64(maxMB)*1024*1024)
|
||||
if err != nil {
|
||||
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
|
||||
log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err)
|
||||
} else {
|
||||
mediaStore = ms
|
||||
defer mediaStore.Close()
|
||||
st := mediaStore.Stats()
|
||||
log.Printf("[homed] media store active: %v 条 / %v 字节(上限 %d MB)",
|
||||
st["count"], st["total_bytes"], maxMB)
|
||||
}
|
||||
}
|
||||
|
||||
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
|
||||
if err := ks.Start(); err != nil {
|
||||
log.Printf("[homed] warning: knowledge store: %v", err)
|
||||
@ -358,6 +382,7 @@ func main() {
|
||||
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 {
|
||||
@ -391,14 +416,25 @@ func main() {
|
||||
- output_* — 输出通道管理(切换/发送消息)
|
||||
- timer_set — 设置定时提醒
|
||||
- plgreload — 热重载插件
|
||||
- spawn_child — 生成子 Agent 执行独立任务
|
||||
- spawn_child — 生成子 Agent 异步执行独立任务(可传 max_turns 控制工具轮数,默认 5)
|
||||
|
||||
并行策略:遇到多个互不依赖的子任务时,优先并行 spawn 多个子 Agent 而非自己串行逐个执行;
|
||||
长耗时任务(批量处理、多轮搜索汇总)也应交给子 Agent,避免阻塞当前对话。
|
||||
- describe_image — 描述用户上传的图片
|
||||
- transcribe_audio — 转写用户上传的音频
|
||||
- ocr_image — 识别图片中的文字
|
||||
|
||||
命令与文件操作策略:
|
||||
- cmd_run 经完整 shell(bash)执行,支持管道、分号、&&、命令替换、heredoc、重定向。
|
||||
- 多步交互式程序(vim/top/ssh 会话、需要持续输入的进程)用 terminal_create 创建终端,
|
||||
terminal_write 发送输入、terminal_read 读输出——不要用 cmd_run 硬等交互程序退出。
|
||||
- 写文件优先 files_write(原子+留档),生成多行内容时可用 heredoc 或 files_write,
|
||||
不要用 echo 拼接长文本。
|
||||
- 读用户发来的文件用 files_read;向 webui 回传图片/文件用 output_send__webui(type=image/file)。
|
||||
|
||||
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。
|
||||
|
||||
回复你的真实想法,用自然语言与用户交流。`
|
||||
回复你的真实想法,用自然语言与用户交流。不要在回复中使用 emoji 表情。`
|
||||
sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultPrompt)
|
||||
if sysPrompt == "" {
|
||||
sysPrompt = defaultPrompt
|
||||
@ -417,6 +453,10 @@ func main() {
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
MediaGCInterval: cfgReg.GetDuration("core.memory.media.gc_interval", 6*time.Hour),
|
||||
MediaGCMinAge: cfgReg.GetDuration("core.memory.media.gc_min_age", time.Hour),
|
||||
MediaDescribe: cfgReg.GetBool("core.memory.media.describe_on_ingest", false),
|
||||
Personality: personality,
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
@ -500,6 +540,14 @@ func main() {
|
||||
}
|
||||
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
||||
|
||||
// 技能索引接线: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)
|
||||
|
||||
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
@ -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
@ -0,0 +1,2 @@
|
||||
# 开发过程截图(体积大、非源码),不入库
|
||||
screenshots/
|
||||
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
@ -0,0 +1,12 @@
|
||||
{
|
||||
"app": {
|
||||
"bundleName": "com.example.homeagent",
|
||||
"vendor": "HomeAgent",
|
||||
"versionCode": 1000000,
|
||||
"versionName": "1.0.0",
|
||||
// 分层图标:前景是字形,背景(沉淀色)在 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
|
After Width: | Height: | Size: 5.9 KiB |
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/background.png
Normal file
|
After Width: | Height: | Size: 989 B |
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/foreground.png
Normal file
|
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
|
After Width: | Height: | Size: 988 B |
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
@ -0,0 +1 @@
|
||||
export { hapTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
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
@ -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"
|
||||
}
|
||||
}
|
||||
217
cmd/ohos/HomeAgent/entry/src/main/ets/common/ApiClient.ets
Normal file
@ -0,0 +1,217 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
195
cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets
Normal file
@ -0,0 +1,195 @@
|
||||
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 { common } from '@kit.AbilityKit';
|
||||
|
||||
// ===== 能力结果 =====
|
||||
|
||||
export interface CapResult {
|
||||
status: string; // 'ok' | 'error'
|
||||
output: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
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> {
|
||||
try {
|
||||
const full: image.PixelMap = await captureScreenPixelMap();
|
||||
const info: image.ImageInfo = await full.getImageInfo();
|
||||
const maxW: number = 720;
|
||||
let targetW: number = info.size.width;
|
||||
let targetH: number = info.size.height;
|
||||
if (targetW > maxW) {
|
||||
targetH = Math.floor(targetH * maxW / targetW);
|
||||
targetW = maxW;
|
||||
}
|
||||
let packed: ArrayBuffer;
|
||||
if (targetW !== info.size.width) {
|
||||
await full.scale(targetW / info.size.width, targetH / info.size.height);
|
||||
}
|
||||
const packer: image.ImagePacker = image.createImagePacker();
|
||||
const opt: image.PackingOption = { format: 'image/jpeg', quality: 70 };
|
||||
packed = await packer.packing(full, opt);
|
||||
packer.release();
|
||||
full.release();
|
||||
const helper: util.Base64Helper = new util.Base64Helper();
|
||||
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
|
||||
return okResult('data:image/jpeg;base64,' + b64);
|
||||
} catch (e) {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('screensee failed: ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== clipboardsee / clipboardsue =====
|
||||
|
||||
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
|
||||
// 说明:READ_PASTEBOARD 为受限权限,调试签名无法在真机安装时授予,
|
||||
// 这里直接尝试读取;系统拒绝时回错误信息。
|
||||
try {
|
||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||
const has: boolean = await clip.hasData();
|
||||
if (!has) {
|
||||
const empty: CapResult = { status: 'ok', output: '', error: '' };
|
||||
return empty;
|
||||
}
|
||||
const data: pasteboard.PasteData = await clip.getData();
|
||||
const txt: string = data.getPrimaryText();
|
||||
const out: CapResult = { status: 'ok', output: txt ?? '', error: '' };
|
||||
return out;
|
||||
} catch (e) {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('clipboardsee failed (需系统剪贴板授权): ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
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('written ' + text.length + ' chars');
|
||||
} catch (e) {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('clipboardsue failed: ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== speakeruse:TTS 朗读 =====
|
||||
|
||||
class TtsSession {
|
||||
private engine: textToSpeech.TextToSpeechEngine | null = null;
|
||||
|
||||
async speak(text: string): Promise<CapResult> {
|
||||
try {
|
||||
if (this.engine === null) {
|
||||
const extra: Record<string, Object> = {
|
||||
'style': 'interaction-broadcast',
|
||||
'locate': 'CN',
|
||||
'name': 'EngineName',
|
||||
};
|
||||
const params: textToSpeech.CreateEngineParams = {
|
||||
language: 'zh-CN',
|
||||
person: 0,
|
||||
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) {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('speakeruse failed: ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ===== deviceinfo =====
|
||||
|
||||
export function capDeviceInfo(): CapResult {
|
||||
const lines: string[] = [];
|
||||
lines.push('brand=' + deviceInfo.brand);
|
||||
lines.push('manufacturer=' + deviceInfo.manufacture);
|
||||
lines.push('model=' + deviceInfo.productModel);
|
||||
lines.push('series=' + deviceInfo.productSeries);
|
||||
lines.push('osFullName=' + deviceInfo.osFullName);
|
||||
lines.push('sdkApiVersion=' + deviceInfo.sdkApiVersion.toString());
|
||||
lines.push('securityPatch=' + deviceInfo.securityPatchTag);
|
||||
lines.push('abiList=' + deviceInfo.abiList);
|
||||
lines.push('deviceType=' + deviceInfo.deviceType);
|
||||
return okResult(lines.join('\n'));
|
||||
}
|
||||
|
||||
// ===== screensue 内容解析 =====
|
||||
// 服务端协议: screensue [秒] <内容>;0=常驻。
|
||||
|
||||
export interface ScreensuePayload {
|
||||
duration: number; // 秒;0 表示常驻直到用户关闭
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function parseScreensue(rawArgs: string): ScreensuePayload {
|
||||
const p: ScreensuePayload = { duration: 5, content: '' };
|
||||
let rest: string = rawArgs.trim();
|
||||
const tokens: string[] = rest.split(/\s+/);
|
||||
if (tokens.length > 1 && /^\d+$/.test(tokens[0])) {
|
||||
p.duration = parseInt(tokens[0], 10);
|
||||
rest = tokens.slice(1).join(' ');
|
||||
} else {
|
||||
rest = tokens.join(' ');
|
||||
}
|
||||
p.content = rest.trim();
|
||||
return p;
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import { deviceBridge, CmdReply } from './DeviceBridge';
|
||||
import {
|
||||
CapResult,
|
||||
capScreensee,
|
||||
capClipboardSee,
|
||||
capClipboardsue,
|
||||
capSpeakerUse,
|
||||
capDeviceInfo,
|
||||
parseScreensue,
|
||||
ScreensuePayload,
|
||||
} from './BridgeCaps';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
// screensue 展示回调由 UI 层注册(Index 挂全局悬浮层)
|
||||
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[] {
|
||||
const cmd: string = command.trim();
|
||||
const idx: number = cmd.indexOf(' ');
|
||||
if (idx < 0) {
|
||||
return [cmd];
|
||||
}
|
||||
const out: string[] = [cmd.substring(0, idx), cmd.substring(idx + 1)];
|
||||
return out;
|
||||
}
|
||||
|
||||
async function executeCommand(reqId: string, command: string): Promise<CapResult> {
|
||||
const parts: string[] = splitCapability(command);
|
||||
const name: string = parts[0];
|
||||
const args: string = parts.length > 1 ? parts[1] : '';
|
||||
|
||||
// screensee 截屏回传(data URL 走文本结果,服务端兼容)
|
||||
if (name === 'screensee') {
|
||||
return capScreensee();
|
||||
}
|
||||
if (name === 'screensue') {
|
||||
const payload: ScreensuePayload = parseScreensue(args);
|
||||
if (screensueHandler !== null) {
|
||||
screensueHandler(payload);
|
||||
return okRes('shown');
|
||||
}
|
||||
return errRes('screensue: display layer not ready');
|
||||
}
|
||||
if (name === 'clipboardsee') {
|
||||
if (appContext === null) {
|
||||
return errRes('clipboardsee: app context missing');
|
||||
}
|
||||
return capClipboardSee(appContext);
|
||||
}
|
||||
if (name === 'clipboardsue') {
|
||||
if (args.length === 0) {
|
||||
return errRes('clipboardsue: empty text');
|
||||
}
|
||||
return capClipboardsue(args);
|
||||
}
|
||||
if (name === 'speakeruse') {
|
||||
if (args.length === 0) {
|
||||
return errRes('speakeruse: empty text');
|
||||
}
|
||||
return capSpeakerUse(args);
|
||||
}
|
||||
if (name === 'deviceinfo' || name === 'status') {
|
||||
return capDeviceInfo();
|
||||
}
|
||||
if (name === 'camerasue') {
|
||||
return errRes('camerasue: camera capture not supported on this build');
|
||||
}
|
||||
if (name === 'computeruse') {
|
||||
return errRes('computeruse: not applicable to touch-only device');
|
||||
}
|
||||
return errRes('unsupported homeagent capability: ' + 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);
|
||||
}
|
||||
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
@ -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;
|
||||
}
|
||||
394
cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets
Normal file
@ -0,0 +1,394 @@
|
||||
import { webSocket } from '@kit.NetworkKit';
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { CapResult } from './BridgeCaps';
|
||||
|
||||
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
||||
|
||||
interface HelloDeviceInfo {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
os_release: string;
|
||||
version: string;
|
||||
cpus: number;
|
||||
}
|
||||
|
||||
interface HelloDevice {
|
||||
device_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
authorized: boolean;
|
||||
caps: string[];
|
||||
info: HelloDeviceInfo;
|
||||
}
|
||||
|
||||
interface HelloMessage {
|
||||
op: string;
|
||||
device: HelloDevice;
|
||||
}
|
||||
|
||||
interface BindMessage {
|
||||
op: string;
|
||||
device_id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface CmdMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
command: string;
|
||||
cmd_type: string;
|
||||
}
|
||||
|
||||
export interface CmdReply {
|
||||
op: string; // 'cmd_result'
|
||||
req_id: string;
|
||||
status: string;
|
||||
output: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface DataStartMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
kind: string;
|
||||
mime: string;
|
||||
total: number;
|
||||
chunk_size: number;
|
||||
}
|
||||
|
||||
interface DataEndMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
status: string;
|
||||
total?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE: number = 8192;
|
||||
|
||||
// ===== 命令处理器回调 =====
|
||||
// 返回 CapResult;二进制大结果通过 dataHandler 分块回传。
|
||||
export type BridgeCmdHandler = (reqId: string, command: string) => Promise<CapResult>;
|
||||
|
||||
export class DeviceBridgeClient {
|
||||
private ws: webSocket.WebSocket = webSocket.createWebSocket();
|
||||
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 everConnected: boolean = false;
|
||||
private manualClose: boolean = false;
|
||||
private reconnectTimer: number = -1;
|
||||
private cmdHandler: BridgeCmdHandler | null = null;
|
||||
private onStateChange: ((open: boolean) => void) | null = null;
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
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> {
|
||||
// 每次连接使用新的 WebSocket 实例,避免旧实例事件残留
|
||||
try {
|
||||
this.ws.off('open');
|
||||
this.ws.off('message');
|
||||
this.ws.off('close');
|
||||
this.ws.off('error');
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
this.ws = webSocket.createWebSocket();
|
||||
this.bindWsEvents(authorized);
|
||||
// 鉴权必须走请求头,不能拼 ?token= :
|
||||
// 1) webui 的 /api/v1/device/* 反代包在 requireAPI 里,
|
||||
// validAPIKey 只认 X-API-Key 头或 Authorization: Bearer,
|
||||
// 查询参数一律视为未授权 → 握手被 401 顶掉,
|
||||
// 表现为 NETSTACK 日志 "Lws client connection error HS: ws upgrade unauthorized"。
|
||||
// 2) 反代到 remotedevice 时会自行注入网关的 ws_token;
|
||||
// 如果我们再带 ?token=<webui apiKey>,remotedevice 的 ServeWS
|
||||
// 会拿它和 ws_token 比对并 401。留空反而放行。
|
||||
const opts: webSocket.WebSocketRequestOptions = {
|
||||
header: this.authHeader(),
|
||||
};
|
||||
try {
|
||||
await this.ws.connect(this.url, opts);
|
||||
} catch (e) {
|
||||
this.connected = false;
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** 握手请求头:X-API-Key + Authorization 双写,兼容不同后端校验实现。 */
|
||||
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(authorized: boolean): void {
|
||||
this.ws.on('open', (err: Error, value: Object) => {
|
||||
this.connected = true;
|
||||
this.everConnected = true;
|
||||
this.cancelReconnect();
|
||||
this.sendHello(authorized);
|
||||
this.sendBind();
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(true);
|
||||
}
|
||||
});
|
||||
this.ws.on('message', (err: Error, value: string | ArrayBuffer) => {
|
||||
if (typeof value === 'string') {
|
||||
this.handleTextFrame(value);
|
||||
}
|
||||
});
|
||||
this.ws.on('close', (err: Error, value: webSocket.CloseResult) => {
|
||||
this.connected = false;
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(false);
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
this.ws.on('error', (err: Error) => {
|
||||
this.connected = false;
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(false);
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新本地授权状态并立即重新 hello 同步到服务端。 */
|
||||
updateAuthorized(authorized: boolean): void {
|
||||
this.lastAuthorized = authorized;
|
||||
if (this.connected) {
|
||||
this.sendHello(authorized);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.manualClose = true;
|
||||
this.cancelReconnect();
|
||||
this.connected = 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
|
||||
}
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
private sendHello(authorized: boolean): void {
|
||||
this.lastAuthorized = authorized;
|
||||
const info: HelloDeviceInfo = {
|
||||
hostname: this.hostname,
|
||||
platform: 'OpenHarmony',
|
||||
arch: '',
|
||||
os_release: '',
|
||||
version: '1.1.0',
|
||||
cpus: 0,
|
||||
};
|
||||
const device: HelloDevice = {
|
||||
device_id: this.deviceId,
|
||||
name: this.name,
|
||||
kind: this.kind,
|
||||
authorized: authorized,
|
||||
caps: this.caps,
|
||||
info: info,
|
||||
};
|
||||
const hello: HelloMessage = { op: 'hello', device: device };
|
||||
this.send(JSON.stringify(hello));
|
||||
}
|
||||
|
||||
private sendBind(): void {
|
||||
const bind: BindMessage = {
|
||||
op: 'bind',
|
||||
device_id: this.deviceId,
|
||||
token: this.token,
|
||||
};
|
||||
this.send(JSON.stringify(bind));
|
||||
}
|
||||
|
||||
// ===== 命令处理 =====
|
||||
|
||||
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 === 'cmd') {
|
||||
const reqId: string = obj['req_id'] as string ?? '';
|
||||
const command: string = obj['command'] as string ?? '';
|
||||
if (reqId.length === 0 || command.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!this.lastAuthorized) {
|
||||
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
|
||||
return;
|
||||
}
|
||||
this.dispatchCommand(reqId, command);
|
||||
} else if (op === 'hello_ack' || op === 'bind_ack') {
|
||||
if (this.onAck !== null) {
|
||||
this.onAck(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onAck: ((op: string) => void) | null = null;
|
||||
|
||||
private dispatchCommand(reqId: string, command: string): void {
|
||||
if (this.cmdHandler === null) {
|
||||
this.sendResult(reqId, 'error', '', 'no capability handler registered');
|
||||
return;
|
||||
}
|
||||
const handler: BridgeCmdHandler = this.cmdHandler;
|
||||
handler(reqId, command).then((res: CapResult) => {
|
||||
this.sendResult(reqId, res.status, res.output, res.error);
|
||||
}).catch((e: Object) => {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
this.sendResult(reqId, 'error', '', msg);
|
||||
});
|
||||
}
|
||||
|
||||
sendResult(reqId: string, status: string, output: string, errMsg: string): void {
|
||||
const result: CmdReply = {
|
||||
op: 'cmd_result',
|
||||
req_id: reqId,
|
||||
status: status,
|
||||
output: output,
|
||||
error: errMsg,
|
||||
};
|
||||
this.send(JSON.stringify(result));
|
||||
}
|
||||
|
||||
// ===== 二进制分块回传(协议与 GUI 客户端一致)=====
|
||||
|
||||
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
|
||||
const startMsg: DataStartMessage = {
|
||||
op: 'cmd_data_start',
|
||||
req_id: reqId,
|
||||
kind: kind,
|
||||
mime: mime,
|
||||
total: bytes.byteLength,
|
||||
chunk_size: CHUNK_SIZE,
|
||||
};
|
||||
this.send(JSON.stringify(startMsg));
|
||||
for (let off: number = 0; off < bytes.byteLength; off += CHUNK_SIZE) {
|
||||
const end: number = Math.min(off + CHUNK_SIZE, bytes.byteLength);
|
||||
const view: Uint8Array = bytes.slice(off, end);
|
||||
const ab: ArrayBuffer = view.buffer as ArrayBuffer;
|
||||
try {
|
||||
this.ws.send(ab).catch(() => {
|
||||
// ignore per-chunk failure; end frame reports error below
|
||||
});
|
||||
} catch (e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const endMsg: DataEndMessage = {
|
||||
op: 'cmd_data_end',
|
||||
req_id: reqId,
|
||||
status: 'ok',
|
||||
};
|
||||
this.send(JSON.stringify(endMsg));
|
||||
}
|
||||
|
||||
sendEvent(eventType: string, detail: string): void {
|
||||
const payload: Record<string, string> = { 'detail': detail };
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'event',
|
||||
'device_id': this.deviceId,
|
||||
'type': eventType,
|
||||
'payload': payload,
|
||||
};
|
||||
this.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
sendStatus(status: string): void {
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'status',
|
||||
'device_id': this.deviceId,
|
||||
'status': status,
|
||||
};
|
||||
this.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
send(text: string): void {
|
||||
if (!this.connected) {
|
||||
return;
|
||||
}
|
||||
this.ws.send(text).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
|
||||
|
||||
export function parseDevicesPayload(jsonStr: string): DeviceInfo[] {
|
||||
return [];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
289
cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets
Normal file
@ -0,0 +1,289 @@
|
||||
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[];
|
||||
}
|
||||
|
||||
// ===== 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';
|
||||
|
||||
class StatusStore {
|
||||
/** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */
|
||||
private groups: StatGroup[] = [];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
getGroups(): StatGroup[] {
|
||||
return this.groups;
|
||||
}
|
||||
|
||||
isUp(): boolean {
|
||||
return AppStorage.get<boolean>(K_UP) ?? false;
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (!apiClient.hasConnection()) {
|
||||
this.fail(noConnectionMessage());
|
||||
return;
|
||||
}
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, true);
|
||||
AppStorage.setOrCreate<string>(K_ERR, '');
|
||||
try {
|
||||
const resp = await apiClient.getWithTimeout('/status', 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
|
||||
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.setOrCreate<boolean>(K_UP, true);
|
||||
AppStorage.setOrCreate<string>(K_VERSION, version);
|
||||
AppStorage.setOrCreate<number>(K_AGENTS, agents);
|
||||
AppStorage.setOrCreate<string>(K_STARTED, startedAt);
|
||||
|
||||
const systemFields: StatField[] = [
|
||||
{ label: '版本', value: version },
|
||||
{ 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);
|
||||
this.groups = groups;
|
||||
this.bump();
|
||||
} catch (e) {
|
||||
this.fail(userMessage('status.refresh', e));
|
||||
}
|
||||
AppStorage.setOrCreate<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.setOrCreate<number>(K_PLUGINS, pluginCount);
|
||||
AppStorage.setOrCreate<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) });
|
||||
}
|
||||
groups.push({ title: '内核', fields: kernelFields });
|
||||
|
||||
// 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 的概览分组
|
||||
}
|
||||
}
|
||||
|
||||
private fail(msg: string): void {
|
||||
AppStorage.setOrCreate<string>(K_ERR, msg);
|
||||
AppStorage.setOrCreate<boolean>(K_UP, false);
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||
this.groups = [];
|
||||
this.bump();
|
||||
}
|
||||
|
||||
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
|
||||
private bump(): void {
|
||||
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
|
||||
AppStorage.setOrCreate<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
@ -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;
|
||||
}
|
||||
463
cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets
Normal file
@ -0,0 +1,463 @@
|
||||
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 { 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';
|
||||
|
||||
/**
|
||||
* 附件解析与展示。
|
||||
*
|
||||
* 后端 Attachment 只有四个字段:type / url / size / name
|
||||
* (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。
|
||||
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
|
||||
*
|
||||
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /api/v1 前缀)。
|
||||
* 这两条路由在后端是 requireWeb,但对 API Key 客户端同等放行,
|
||||
* 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。
|
||||
*/
|
||||
|
||||
/** 从后端 JSON 里解析 attachment 字段;缺字段或类型不对则返回 undefined。 */
|
||||
export function parseAttachment(raw: Object | undefined): ChatAttachment | undefined {
|
||||
if (raw === undefined || raw === null) {
|
||||
return undefined;
|
||||
}
|
||||
const o: Record<string, Object> = raw as Record<string, Object>;
|
||||
const url: string = o['url'] as string ?? '';
|
||||
if (url.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const t: string = o['type'] as string ?? 'file';
|
||||
const a: ChatAttachment = {
|
||||
type: t === 'image' ? 'image' : 'file',
|
||||
url: url,
|
||||
size: o['size'] as number ?? 0,
|
||||
name: o['name'] as string ?? fileNameOf(url),
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
/** 由 SSE channel_output 事件构造附件(字段名与 history 不同)。 */
|
||||
export function attachmentFromChannelOutput(
|
||||
outputType: string, url: string, size: number): ChatAttachment | undefined {
|
||||
if (url.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (outputType !== 'image' && outputType !== 'file') {
|
||||
return undefined;
|
||||
}
|
||||
const a: ChatAttachment = {
|
||||
type: outputType,
|
||||
url: url,
|
||||
size: size,
|
||||
name: fileNameOf(url),
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
/** 取 URL 最后一段作为展示文件名,与后端 handler.go 的取名方式一致。 */
|
||||
export function fileNameOf(url: string): string {
|
||||
let s: string = url;
|
||||
const q: number = s.indexOf('?');
|
||||
if (q >= 0) {
|
||||
s = s.substring(0, q);
|
||||
}
|
||||
const i: number = s.lastIndexOf('/');
|
||||
const name: string = i >= 0 ? s.substring(i + 1) : s;
|
||||
return name.length > 0 ? name : '附件';
|
||||
}
|
||||
|
||||
/** 人类可读字节数,口径对齐后端 formatBytes(KB 以上保留一位小数)。 */
|
||||
export function formatBytes(n: number): string {
|
||||
if (n <= 0) {
|
||||
return '';
|
||||
}
|
||||
if (n < 1024) {
|
||||
return n.toString() + ' B';
|
||||
}
|
||||
const kb: number = n / 1024;
|
||||
if (kb < 1024) {
|
||||
return oneDecimal(kb) + ' KB';
|
||||
}
|
||||
const mb: number = kb / 1024;
|
||||
if (mb < 1024) {
|
||||
return oneDecimal(mb) + ' MB';
|
||||
}
|
||||
return oneDecimal(mb / 1024) + ' GB';
|
||||
}
|
||||
|
||||
function oneDecimal(v: number): string {
|
||||
return (Math.round(v * 10) / 10).toString();
|
||||
}
|
||||
|
||||
/** 由文件名后缀猜测类型标签。后端不返回 mime,只能这样标注。 */
|
||||
export function extLabel(name: string): string {
|
||||
const i: number = name.lastIndexOf('.');
|
||||
if (i < 0 || i === name.length - 1) {
|
||||
return '未知类型';
|
||||
}
|
||||
return name.substring(i + 1).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡内的附件卡:图片显示缩略图,文件显示一枚文件条。
|
||||
* 点击进入附件详情二级页面(WebGUI 是新开标签页,移动端改为二级页)。
|
||||
*/
|
||||
@Component
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载并解码成 PixelMap;任何一步失败都返回 undefined(调用方显示占位)。 */
|
||||
async function loadPixelMap(url: string): Promise<image.PixelMap | undefined> {
|
||||
try {
|
||||
// 本地待上传的图片:直接读沙箱文件,不走网络
|
||||
if (url.startsWith('file://')) {
|
||||
const path: string = url.substring(7);
|
||||
const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
|
||||
const localSrc: image.ImageSource = image.createImageSource(f.fd);
|
||||
const localPm: image.PixelMap = await localSrc.createPixelMap();
|
||||
await localSrc.release();
|
||||
fileIo.closeSync(f);
|
||||
return localPm;
|
||||
}
|
||||
const abs: string = apiClient.absoluteUrl(url);
|
||||
const resp = await apiClient.getBinary(abs, 15000);
|
||||
const src: image.ImageSource = image.createImageSource(resp.data);
|
||||
const pm: image.PixelMap = await src.createPixelMap();
|
||||
await src.release();
|
||||
return pm;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 去掉路径分隔符,避免附件名把文件写到 filesDir 之外。 */
|
||||
function sanitize(name: string): string {
|
||||
let s: string = name.replace(/[\/\\:*?"<>|]/g, '_');
|
||||
if (s.length === 0) {
|
||||
s = 'attachment';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@ -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
@ -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,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,604 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Covers: headings, paragraphs, code fences, unordered/ordered lists,
|
||||
* blockquotes, horizontal rules, tables, and inline bold/italic/code/links.
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { RADIUS_SM } from '../common/Constants';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MdBlock {
|
||||
type: string; // 'heading' | 'code' | 'list' | 'ol' | 'blockquote' | 'hr' | 'table' | 'para'
|
||||
level?: number;
|
||||
items?: string[];
|
||||
text?: string;
|
||||
lang?: string;
|
||||
codeLines?: string[];
|
||||
headers?: string[];
|
||||
rows?: string[][];
|
||||
}
|
||||
|
||||
export interface MdSpan {
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
code?: boolean;
|
||||
link?: boolean;
|
||||
linkUrl?: string;
|
||||
}
|
||||
|
||||
// ── Inline parser ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function parseInline(text: string): MdSpan[] {
|
||||
const spans: MdSpan[] = [];
|
||||
let i: number = 0;
|
||||
while (i < text.length) {
|
||||
// Inline code (backtick)
|
||||
if (text[i] === '`') {
|
||||
const end: number = text.indexOf('`', i + 1);
|
||||
if (end > i) {
|
||||
spans.push({ text: text.substring(i + 1, end), code: true });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Bold: **text**
|
||||
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '*') {
|
||||
const end: number = text.indexOf('**', i + 2);
|
||||
if (end > i + 1) {
|
||||
spans.push({ text: text.substring(i + 2, end), bold: true });
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Italic: *text* (single asterisk)
|
||||
if (text[i] === '*' && (i + 1 >= text.length || text[i + 1] !== '*')) {
|
||||
const end: number = text.indexOf('*', i + 1);
|
||||
if (end > i) {
|
||||
spans.push({ text: text.substring(i + 1, end), italic: true });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Link: [text](url)
|
||||
if (text[i] === '[') {
|
||||
const cb: number = text.indexOf(']', i + 1);
|
||||
if (cb > i && cb + 1 < text.length && text[cb + 1] === '(') {
|
||||
const cp: number = text.indexOf(')', cb + 2);
|
||||
if (cp > cb + 1) {
|
||||
spans.push({ text: text.substring(i + 1, cb), link: true, linkUrl: text.substring(cb + 2, cp) });
|
||||
i = cp + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Plain run
|
||||
let j: number = i + 1;
|
||||
while (j < text.length && text[j] !== '`' && text[j] !== '*' && text[j] !== '[') {
|
||||
j++;
|
||||
}
|
||||
spans.push({ text: text.substring(i, j) });
|
||||
i = j;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
// ── Block parser helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function isHr(line: string): boolean {
|
||||
if (line.length < 3) {
|
||||
return false;
|
||||
}
|
||||
const ch: string = line[0];
|
||||
if (ch !== '-' && ch !== '*' && ch !== '_') {
|
||||
return false;
|
||||
}
|
||||
for (let k = 0; k < line.length; k++) {
|
||||
if (line[k] !== ch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isOlStart(line: string): boolean {
|
||||
if (line.length < 3) {
|
||||
return false;
|
||||
}
|
||||
let k: number = 0;
|
||||
while (k < line.length && line[k] >= '0' && line[k] <= '9') {
|
||||
k++;
|
||||
}
|
||||
return k > 0 && k + 1 < line.length && line[k] === '.' && line[k + 1] === ' ';
|
||||
}
|
||||
|
||||
function isTableSep(line: string): boolean {
|
||||
if (!line.includes('-')) {
|
||||
return false;
|
||||
}
|
||||
for (let k = 0; k < line.length; k++) {
|
||||
const c: string = line[k];
|
||||
if (c !== '|' && c !== '-' && c !== ':' && c !== ' ' && c !== '\t') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Block parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function parseBlocks(content: string): MdBlock[] {
|
||||
if (content.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const lines: string[] = content.split('\n');
|
||||
const blocks: MdBlock[] = [];
|
||||
let i: number = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line: string = lines[i];
|
||||
|
||||
// Empty line
|
||||
if (line.trim().length === 0) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code fence
|
||||
if (line.startsWith('```')) {
|
||||
const langEnd: number = line.indexOf('`', 3);
|
||||
const lang: string = langEnd > 3 ? line.substring(3, langEnd).trim() : '';
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) {
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'code', lang: lang, codeLines: codeLines });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading
|
||||
if (line.startsWith('#')) {
|
||||
let level: number = 0;
|
||||
while (level < line.length && line[level] === '#') {
|
||||
level++;
|
||||
}
|
||||
if (level <= 6 && level < line.length && line[level] === ' ') {
|
||||
blocks.push({ type: 'heading', level: level, text: line.substring(level + 1).trim() });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (isHr(line.trim())) {
|
||||
blocks.push({ type: 'hr' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if ((line.startsWith('- ') || line.startsWith('* ')) && !line.startsWith('- [')) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && (lines[i].startsWith('- ') || lines[i].startsWith('* ')) && !lines[i].startsWith('- [')) {
|
||||
items.push(lines[i].substring(2));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'list', items: items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if (isOlStart(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && isOlStart(lines[i])) {
|
||||
const dotIdx: number = lines[i].indexOf('. ');
|
||||
items.push(lines[i].substring(dotIdx + 2));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'ol', items: items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith('> ')) {
|
||||
const qLines: string[] = [];
|
||||
while (i < lines.length && lines[i].startsWith('> ')) {
|
||||
qLines.push(lines[i].substring(2));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'blockquote', text: qLines.join('\n') });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table
|
||||
if (line.trimStart().startsWith('|') && !isTableSep(line)) {
|
||||
const tLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trimStart().startsWith('|')) {
|
||||
tLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (tLines.length >= 2) {
|
||||
const parseRow = (row: string): string[] => {
|
||||
const cells: string[] = [];
|
||||
const parts: string[] = row.split('|');
|
||||
for (let p = 0; p < parts.length; p++) {
|
||||
const c: string = parts[p].trim();
|
||||
if (c.length > 0) {
|
||||
cells.push(c);
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
};
|
||||
const headers: string[] = parseRow(tLines[0]);
|
||||
const rows: string[][] = [];
|
||||
for (let k = 1; k < tLines.length; k++) {
|
||||
if (!isTableSep(tLines[k].trim())) {
|
||||
rows.push(parseRow(tLines[k]));
|
||||
}
|
||||
}
|
||||
if (headers.length > 0) {
|
||||
blocks.push({ type: 'table', headers: headers, rows: rows });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph: collect consecutive non-special lines
|
||||
{
|
||||
const paraLines: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const ln: string = lines[i];
|
||||
if (ln.trim().length === 0) {
|
||||
break;
|
||||
}
|
||||
if (ln.startsWith('```') || ln.startsWith('#') || isHr(ln.trim())) {
|
||||
break;
|
||||
}
|
||||
if (ln.startsWith('- ') || ln.startsWith('* ') || isOlStart(ln) || ln.startsWith('> ')) {
|
||||
break;
|
||||
}
|
||||
if (ln.trimStart().startsWith('|') && !isTableSep(ln)) {
|
||||
break;
|
||||
}
|
||||
paraLines.push(ln);
|
||||
i++;
|
||||
}
|
||||
if (paraLines.length > 0) {
|
||||
blocks.push({ type: 'para', text: paraLines.join('\n') });
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Component
|
||||
export struct StaticMarkdownView {
|
||||
@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)
|
||||
}
|
||||
}
|
||||
323
cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets
Normal file
@ -0,0 +1,323 @@
|
||||
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 {
|
||||
statusStore, StatGroup, StatField, compactDuration,
|
||||
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV,
|
||||
} 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 = '-';
|
||||
@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)
|
||||
Text(this.version)
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ left: 10 })
|
||||
Text('明细')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().accent)
|
||||
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 个工具旁边只剩一条短线 —— 读数没有意义。
|
||||
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
360
cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets
Normal file
@ -0,0 +1,360 @@
|
||||
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 {
|
||||
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,138 @@
|
||||
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';
|
||||
|
||||
/** 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 {
|
||||
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();
|
||||
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();
|
||||
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 {
|
||||
console.info('[HomeAgent] ability onForeground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
console.info('[HomeAgent] ability onBackground');
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
1962
cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets
Normal file
692
cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets
Normal file
@ -0,0 +1,692 @@
|
||||
import { deviceBridge } from '../common/DeviceBridge';
|
||||
import { installCmdRouter, registerScreensueHandler, setBridgeAppContext } from '../common/BridgeRouter';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { noConnectionMessage } from '../common/UserError';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
|
||||
// 本机声明的能力(与 BridgeRouter 支持的命令一一对应)
|
||||
const LOCAL_CAPS: string[] = [
|
||||
'status',
|
||||
'deviceinfo',
|
||||
'screensee',
|
||||
'screensue',
|
||||
'clipboardsee',
|
||||
'clipboardsue',
|
||||
'speakeruse',
|
||||
];
|
||||
|
||||
/** 二级页面标识 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_LOCAL: string = 'local';
|
||||
const SUB_CAPS: string = 'caps';
|
||||
const SUB_GATEWAY: string = 'gateway';
|
||||
const SUB_LIST: string = 'list';
|
||||
|
||||
@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;
|
||||
@State bridgeConnected: boolean = false;
|
||||
@State bridgeUrl: string = '';
|
||||
@State bridgeToken: string = '';
|
||||
@State deviceId: string = '';
|
||||
@State authorized: boolean = false;
|
||||
@State devices: DeviceInfo[] = [];
|
||||
@State loadingDevices: boolean = false;
|
||||
@State lastError: string = '';
|
||||
@State toastMsg: string = '';
|
||||
@State toastIsError: boolean = false;
|
||||
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
|
||||
private navStack: NavPathStack = new NavPathStack();
|
||||
private autoConnectTried: boolean = false;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.deviceId = deviceBridge.getDeviceId();
|
||||
if (this.deviceId.length === 0) {
|
||||
this.deviceId = connStore.getDeviceId();
|
||||
}
|
||||
if (this.deviceId.length === 0) {
|
||||
this.deviceId = 'ohos-' + Date.now().toString(36);
|
||||
try {
|
||||
connStore.saveDeviceId(this.deviceId);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.authorized = connStore.getDeviceAuth();
|
||||
// Gateway URL derives from current connection
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
this.bridgeUrl = this.gatewayUrlOf(cur.url);
|
||||
this.bridgeToken = cur.apiKey;
|
||||
}
|
||||
installCmdRouter();
|
||||
try {
|
||||
setBridgeAppContext(getContext(this) as common.UIAbilityContext);
|
||||
} catch (e) {
|
||||
// ignore context errors
|
||||
}
|
||||
deviceBridge.setStateListener((open: boolean) => {
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.bridgeConnected = open;
|
||||
});
|
||||
if (open) {
|
||||
this.lastError = '';
|
||||
this.showToast('设备网关已连接', false);
|
||||
this.refreshDevices();
|
||||
}
|
||||
});
|
||||
this.refreshDevices();
|
||||
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
|
||||
registerNavStack(2, this.navStack, () => {
|
||||
this.activeSub = SUB_NONE;
|
||||
});
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
unregisterNavStack(2);
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把后端 HTTP 地址转成设备桥的 WebSocket 地址。
|
||||
*
|
||||
* 关键:@ohos.net.webSocket 只接受 ws:// / wss:// 协议头,
|
||||
* 直接把 http:// 传进 connect() 会在 native 层报
|
||||
* "protocol failed" + "ParseUrl failed"(NETSTACK websocket_exec.cpp),
|
||||
* 表现为设备通道永远连不上。所以这里必须做协议替换。
|
||||
*/
|
||||
private gatewayUrlOf(base: string): string {
|
||||
let trimmed: string = base.trim();
|
||||
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
||||
trimmed = trimmed.substring(0, trimmed.length - 1);
|
||||
}
|
||||
let scheme: string = 'ws://';
|
||||
let rest: string = trimmed;
|
||||
if (trimmed.startsWith('https://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('https://'.length);
|
||||
} else if (trimmed.startsWith('http://')) {
|
||||
scheme = 'ws://';
|
||||
rest = trimmed.substring('http://'.length);
|
||||
} else if (trimmed.startsWith('wss://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('wss://'.length);
|
||||
} else if (trimmed.startsWith('ws://')) {
|
||||
scheme = 'ws://';
|
||||
rest = trimmed.substring('ws://'.length);
|
||||
}
|
||||
return scheme + rest + '/api/v1/device/ws';
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开二级页面。
|
||||
*
|
||||
* 窄屏(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);
|
||||
if (!this.bridgeConnected) {
|
||||
this.connectBridge();
|
||||
}
|
||||
this.showToast(on ? '已授权,agent 可下发能力命令' : '已取消授权', false);
|
||||
}
|
||||
|
||||
// ===================== gateway connection =====================
|
||||
|
||||
private async connectBridge(): Promise<void> {
|
||||
if (this.bridgeUrl.length === 0 || this.bridgeToken.length === 0) {
|
||||
this.lastError = noConnectionMessage();
|
||||
return;
|
||||
}
|
||||
this.lastError = '';
|
||||
if (this.deviceId.length === 0) {
|
||||
this.deviceId = 'ohos-' + Date.now().toString(36);
|
||||
}
|
||||
try {
|
||||
connStore.saveDeviceId(this.deviceId);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
const name: string = 'HomeAgent OHOS';
|
||||
await deviceBridge.connect(
|
||||
this.bridgeUrl, this.bridgeToken, this.deviceId,
|
||||
LOCAL_CAPS, 'ohos-phone', this.authorized, name);
|
||||
}
|
||||
|
||||
/** 首次进入自动尝试连接(静默,失败不打扰)。 */
|
||||
private maybeAutoConnect(): void {
|
||||
if (this.autoConnectTried || this.bridgeConnected) {
|
||||
return;
|
||||
}
|
||||
this.autoConnectTried = true;
|
||||
if (this.bridgeUrl.length > 0 && this.bridgeToken.length > 0) {
|
||||
this.connectBridge();
|
||||
}
|
||||
}
|
||||
|
||||
private showToast(msg: string, isError: boolean): void {
|
||||
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
|
||||
this.toastIsError = isError;
|
||||
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 devs: Object = parsed['devices'];
|
||||
const list: DeviceInfo[] = [];
|
||||
if (devs !== undefined && devs !== null) {
|
||||
const arr: Object[] = devs as Object[];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const d: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const capsArr: Object = d['caps'];
|
||||
const caps: string[] = [];
|
||||
if (capsArr !== undefined && capsArr !== null) {
|
||||
const cArr: Object[] = capsArr as Object[];
|
||||
for (let j = 0; j < cArr.length; j++) {
|
||||
caps.push(cArr[j] as string);
|
||||
}
|
||||
}
|
||||
const info: DeviceInfo = {
|
||||
deviceId: d['device_id'] as string ?? '',
|
||||
name: d['name'] as string ?? '',
|
||||
kind: d['kind'] as string ?? '',
|
||||
online: true,
|
||||
authorized: d['authorized'] as boolean ?? false,
|
||||
caps: caps,
|
||||
};
|
||||
list.push(info);
|
||||
}
|
||||
}
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.devices = list;
|
||||
});
|
||||
} else {
|
||||
this.devices = [];
|
||||
}
|
||||
} catch (e) {
|
||||
// 网络失败保持静默,不打扰用户
|
||||
}
|
||||
this.loadingDevices = false;
|
||||
}
|
||||
|
||||
build() {
|
||||
// Navigation 提供真实导航栈:系统侧滑返回 / 三键返回都会自动 pop
|
||||
Navigation(this.navStack) {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
this.RootEntries()
|
||||
}
|
||||
.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);
|
||||
})
|
||||
.onAppear(() => {
|
||||
this.maybeAutoConnect();
|
||||
})
|
||||
}
|
||||
.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);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 二级页面路由表 */
|
||||
@Builder
|
||||
SubDestination(name: string, param: object) {
|
||||
NavDestination() {
|
||||
if (name === SUB_LOCAL) {
|
||||
SubPageLayer({
|
||||
title: '本机设备',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
}) {
|
||||
this.LocalDeviceContent()
|
||||
}
|
||||
} else if (name === SUB_CAPS) {
|
||||
SubPageLayer({
|
||||
title: '设备能力',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
}) {
|
||||
this.CapsContent()
|
||||
}
|
||||
} else if (name === SUB_GATEWAY) {
|
||||
SubPageLayer({
|
||||
title: '设备通道',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
}) {
|
||||
this.GatewayContent()
|
||||
}
|
||||
} else if (name === SUB_LIST) {
|
||||
SubPageLayer({
|
||||
title: '接入的设备',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
showRefresh: true,
|
||||
onRefresh: () => {
|
||||
this.refreshDevices();
|
||||
},
|
||||
}) {
|
||||
this.OnlineDevicesContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
|
||||
|
||||
@Builder
|
||||
Toast() {
|
||||
if (this.toastMsg.length > 0) {
|
||||
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 }))
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 一级入口列表 =====================
|
||||
|
||||
@Builder
|
||||
RootEntries() {
|
||||
NavGroup({ caption: '本机' }) {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_phone'),
|
||||
title: '本机设备',
|
||||
subtitle: this.deviceId.length > 0 ? this.deviceId : '未注册',
|
||||
value: this.bridgeConnected ? '在线' : '离线',
|
||||
selected: this.isWide && this.activeSub === SUB_LOCAL,
|
||||
onTap: () => {
|
||||
this.openSub(SUB_LOCAL);
|
||||
},
|
||||
})
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_bolt'),
|
||||
title: '设备能力',
|
||||
subtitle: this.capsCount().toString() + ' 项能力',
|
||||
value: '',
|
||||
showDivider: false,
|
||||
selected: this.isWide && this.activeSub === SUB_CAPS,
|
||||
onTap: () => {
|
||||
this.openSub(SUB_CAPS);
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
NavGroup({ caption: '通道' }) {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_gateway'),
|
||||
title: '设备通道',
|
||||
subtitle: this.bridgeUrl.length > 0 ? '网关已配置' : '未配置',
|
||||
value: this.bridgeConnected ? '已连接' : '未连接',
|
||||
selected: this.isWide && this.activeSub === SUB_GATEWAY,
|
||||
onTap: () => {
|
||||
this.openSub(SUB_GATEWAY);
|
||||
},
|
||||
})
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_devices_multi'),
|
||||
title: '接入的设备',
|
||||
subtitle: this.loadingDevices ? '加载中...' : '当前在线',
|
||||
value: this.devices.length.toString() + ' 台',
|
||||
showDivider: false,
|
||||
selected: this.isWide && this.activeSub === SUB_LIST,
|
||||
onTap: () => {
|
||||
this.openSub(SUB_LIST);
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private capsCount(): number {
|
||||
return LOCAL_CAPS.length;
|
||||
}
|
||||
|
||||
// ===================== 二级:本机设备 =====================
|
||||
|
||||
@Builder
|
||||
LocalDeviceContent() {
|
||||
PlainCard({ caption: '基本信息' }) {
|
||||
this.KvRow('设备 ID', this.deviceId.length > 0 ? this.deviceId : '未注册')
|
||||
this.KvRow('名称', 'HomeAgent OHOS')
|
||||
this.KvRow('类型', 'phone')
|
||||
}
|
||||
|
||||
PlainCard({ caption: '权限控制' }) {
|
||||
Row() {
|
||||
Column({ space: 2 }) {
|
||||
Text('允许 agent 控制本机')
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text('授权后 agent 可调用下方能力;截屏仅捕获本应用画面,剪贴板读取需系统弹窗确认。')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Toggle({ type: ToggleType.Switch, isOn: this.authorized })
|
||||
.selectedColor(this.palette().accent)
|
||||
.onChange((on: boolean) => {
|
||||
this.toggleAuthorized(on);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(this.authorized ? '#17A964' : '#E84026')
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.margin({ right: 8 })
|
||||
Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
|
||||
.fontSize(12)
|
||||
.fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 12 })
|
||||
.padding({ left: 4 })
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 二级:设备能力 =====================
|
||||
|
||||
@Builder
|
||||
CapsContent() {
|
||||
PlainCard({ caption: '能力清单' }) {
|
||||
Text('agent 通过设备桥可调用的本机能力:')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(LOCAL_CAPS, (cap: string) => {
|
||||
Text(cap)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().accent)
|
||||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||||
.borderRadius(999)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.margin({ right: 6, bottom: 6 })
|
||||
}, (cap: string) => cap)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 二级:设备通道 =====================
|
||||
|
||||
@Builder
|
||||
GatewayContent() {
|
||||
PlainCard({ caption: '连接信息' }) {
|
||||
this.KvRow('网关地址', this.bridgeUrl.length > 0 ? this.bridgeUrl : '-')
|
||||
this.KvRow('Token', this.bridgeToken.length > 0 ? '已从连接继承' : '未配置')
|
||||
this.KvRow('状态', this.bridgeConnected ? '已连接' : '未连接')
|
||||
}
|
||||
|
||||
if (this.lastError.length > 0) {
|
||||
Text(this.lastError)
|
||||
.fontSize(12)
|
||||
.fontColor('#E84026')
|
||||
.padding({ left: 4 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: -8 }))
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
// 缩放反馈由 MotionBase 统一;连接态的底色/字色切换仍需按钮自己缓动,
|
||||
// 因为父容器的 .animation() 到不了子节点。
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button(this.bridgeConnected ? '断开' : '连接网关')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
|
||||
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.border({
|
||||
width: this.bridgeConnected ? 1 : 0,
|
||||
color: this.palette().btnGhostBorder,
|
||||
})
|
||||
.onClick(() => {
|
||||
if (this.bridgeConnected) {
|
||||
deviceBridge.disconnect();
|
||||
} else {
|
||||
this.connectBridge();
|
||||
}
|
||||
})
|
||||
}
|
||||
Blank()
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button('刷新设备')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.onClick(() => {
|
||||
this.refreshDevices();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态,bind 携带 Token 完成身份绑定。')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 10 })
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 二级:接入的设备 =====================
|
||||
|
||||
@Builder
|
||||
OnlineDevicesContent() {
|
||||
if (this.devices.length === 0) {
|
||||
Text('暂无其他设备。电脑 GUI 或 CLI 连接同一网关后会出现在这里。')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding({ left: 4 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
ForEach(this.devices, (dev: DeviceInfo) => {
|
||||
// PlainCard 是自定义组件,transition 不能直接挂在它上面(会生成 __Common__ 包装),
|
||||
// 所以用一个无 padding、满宽的 Column 承载入场动画,布局不受影响。
|
||||
Column() {
|
||||
PlainCard({ caption: '' }) {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(dev.online ? '#17A964' : '#77809A')
|
||||
.margin({ right: 10 })
|
||||
Column() {
|
||||
Text(dev.name.length > 0 ? dev.name : dev.deviceId)
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(dev.kind + (dev.authorized ? ' · 已授权' : ' · 未授权'))
|
||||
.fontSize(11)
|
||||
.fontColor(dev.authorized ? '#17A964' : this.palette().textMuted)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text(dev.caps.length.toString() + ' 能力')
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}, (dev: DeviceInfo) => dev.deviceId + dev.online.toString())
|
||||
}
|
||||
|
||||
// ===================== 通用 KV 行 =====================
|
||||
|
||||
@Builder
|
||||
KvRow(k: string, v: string) {
|
||||
Row() {
|
||||
Text(k)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
Blank()
|
||||
Text(v)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.border({ width: { bottom: 1 }, color: this.palette().kvBorder })
|
||||
}
|
||||
}
|
||||
|
||||
const RADIUS_MD: number = 10;
|
||||
398
cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets
Normal file
@ -0,0 +1,398 @@
|
||||
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 { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter';
|
||||
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();
|
||||
// 设备桥:注册命令路由与 screensue 悬浮层回调
|
||||
installCmdRouter();
|
||||
registerScreensueHandler((payload: ScreensuePayload) => {
|
||||
this.showScreensue(payload);
|
||||
});
|
||||
}
|
||||
|
||||
/** 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.setOrCreate<boolean>('isWideScreen', wide);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的返回处理:把返回事件精确派发给【当前 Tab】的那个 Navigation 栈。
|
||||
*
|
||||
* 四个页面各自持有 Navigation(宽屏需要"左一级右二级"),它们在 Swiper 里
|
||||
* 同时存活,系统返回落到哪个栈并不确定 —— 这就是"有的页面无法使用返回手势"
|
||||
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
|
||||
*/
|
||||
onBackPress(): boolean {
|
||||
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 悬浮层:agent 推送给用户看的内容(置顶展示)
|
||||
if (this.screensueVisible) {
|
||||
Column() {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(this.palette().accent)
|
||||
Text('agent 推送')
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ left: 8 })
|
||||
Blank()
|
||||
if (this.screensueCountdown > 0) {
|
||||
Text(this.screensueCountdown.toString() + 's')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
Text('关闭')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().accent)
|
||||
.padding({ left: 10, right: 2, top: 4, bottom: 4 })
|
||||
.onClick(() => {
|
||||
this.closeScreensue();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
Scroll() {
|
||||
Text(this.screensueText)
|
||||
.fontSize(15)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
}
|
||||
.constraintSize({ maxHeight: 320 })
|
||||
.scrollBar(BarState.Auto)
|
||||
.align(Alignment.Top)
|
||||
}
|
||||
.width('86%')
|
||||
.padding(18)
|
||||
.borderRadius(18)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.shadow({ radius: 32, color: this.palette().shadow, offsetY: 10 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.id(snapshotComponentId())
|
||||
// 实时跟随窗口宽度:平板旋转、折叠展开、分屏、自由窗口拖拽都会回调,
|
||||
// 比启动时查一次 display 更可靠(不会漏掉运行中的尺寸变化)。
|
||||
.onAreaChange((oldValue: Area, newValue: Area) => {
|
||||
this.updateWideScreen(newValue.width as number);
|
||||
})
|
||||
}
|
||||
}
|
||||
995
cmd/ohos/HomeAgent/entry/src/main/ets/pages/PluginsPage.ets
Normal file
@ -0,0 +1,995 @@
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage, noConnectionMessage } from '../common/UserError';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
||||
import { RADIUS_LG, RADIUS_MD, RADIUS_SM, RADIUS_PILL, COLOR_ERROR } from '../common/Constants';
|
||||
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, GlassShell, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { SettingsEditor } from '../components/SettingsEditor';
|
||||
|
||||
interface InstallBody {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** 二级页面标识:插件详情 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_DETAIL: string = 'detail';
|
||||
|
||||
@Component
|
||||
export struct PluginsPage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源对齐 WebGUI renderPlugins:
|
||||
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools(按 plugin 归属)
|
||||
* - GET /plugins → 已安装外部插件元数据(version/description 等)
|
||||
* - GET /plugins/disabled → {disabled:[{name,...}]}
|
||||
* 三方按名称合并去重排序。
|
||||
*/
|
||||
private async loadPlugins(): Promise<void> {
|
||||
if (!apiClient.hasConnection()) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
// ---- kernel: loaded plugins + tool ownership ----
|
||||
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
|
||||
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
|
||||
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
|
||||
const kpRaw: Object | undefined = kernelObj['plugins'];
|
||||
if (kpRaw !== undefined && kpRaw !== null) {
|
||||
const kpArr: Object[] = kpRaw as Object[];
|
||||
for (let i = 0; i < kpArr.length; i++) {
|
||||
const item: Record<string, Object> = kpArr[i] as Record<string, Object>;
|
||||
const n: string = item['name'] as string ?? '';
|
||||
if (n.length === 0) {
|
||||
continue;
|
||||
}
|
||||
loadedMap.set(n, item['loaded'] as boolean ?? true);
|
||||
}
|
||||
}
|
||||
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
|
||||
const tRaw: Object | undefined = kernelObj['tools'];
|
||||
if (tRaw !== undefined && tRaw !== null) {
|
||||
const tArr: Object[] = tRaw as Object[];
|
||||
for (let i = 0; i < tArr.length; i++) {
|
||||
const item: Record<string, Object> = tArr[i] as Record<string, Object>;
|
||||
const tn: string = item['name'] as string ?? '';
|
||||
const owner: string = item['plugin'] as string ?? '';
|
||||
if (tn.length === 0 || owner.length === 0) {
|
||||
continue;
|
||||
}
|
||||
let list: string[] | undefined = toolsByPlugin.get(owner);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
toolsByPlugin.set(owner, list);
|
||||
}
|
||||
// 每插件最多展示 8 个工具名,避免卡片过长
|
||||
if (list.length < 8) {
|
||||
list.push(tn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- installed external plugins metadata ----
|
||||
const externalMeta: Map<string, Record<string, Object>> = new Map<string, Record<string, Object>>();
|
||||
try {
|
||||
const pResp = await apiClient.getWithTimeout('/plugins', 10000);
|
||||
const bodyTrim = pResp.body.trim();
|
||||
let arr: Object[] = [];
|
||||
if (bodyTrim.length > 0 && bodyTrim.charAt(0) === '[') {
|
||||
arr = JSON.parse(pResp.body) as Object[];
|
||||
} else {
|
||||
const obj: Record<string, Object> = JSON.parse(pResp.body) as Record<string, Object>;
|
||||
const rawList: Object = obj['plugins'] ?? obj['data'];
|
||||
if (rawList !== undefined && rawList !== null) {
|
||||
arr = rawList as Object[];
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const n: string = item['name'] as string ?? '';
|
||||
if (n.length > 0) {
|
||||
externalMeta.set(n, item);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 外部列表失败不阻塞内置展示
|
||||
}
|
||||
|
||||
// ---- disabled list ----
|
||||
const disabledNames: Set<string> = new Set<string>();
|
||||
try {
|
||||
const dResp = await apiClient.getWithTimeout('/plugins/disabled', 8000);
|
||||
const dObj: Record<string, Object> = JSON.parse(dResp.body) as Record<string, Object>;
|
||||
const dArr: Object | undefined = dObj['disabled'];
|
||||
if (dArr !== undefined && dArr !== null) {
|
||||
const items: Object[] = dArr as Object[];
|
||||
for (let di = 0; di < items.length; di++) {
|
||||
const dItem: Record<string, Object> = items[di] as Record<string, Object>;
|
||||
const dn: string = dItem['name'] as string ?? '';
|
||||
if (dn.length > 0) {
|
||||
disabledNames.add(dn);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// disabled endpoint may not exist; ignore
|
||||
}
|
||||
|
||||
// ---- merge: allNames sorted(与 GUI 一致)----
|
||||
const allNames: Set<string> = new Set<string>();
|
||||
loadedMap.forEach((v: boolean, k: string) => {
|
||||
allNames.add(k);
|
||||
});
|
||||
externalMeta.forEach((v: Record<string, Object>, k: string) => {
|
||||
allNames.add(k);
|
||||
});
|
||||
disabledNames.forEach((n: string) => {
|
||||
allNames.add(n);
|
||||
});
|
||||
const names: string[] = Array.from(allNames);
|
||||
names.sort();
|
||||
|
||||
const rows: PluginRow[] = [];
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const name: string = names[i];
|
||||
const meta: Record<string, Object> | undefined = externalMeta.get(name);
|
||||
const tools: string[] | undefined = toolsByPlugin.get(name);
|
||||
const row: PluginRow = {
|
||||
name: name,
|
||||
loaded: loadedMap.get(name) ?? false,
|
||||
disabled: disabledNames.has(name),
|
||||
external: externalMeta.has(name),
|
||||
version: meta !== undefined ? meta['version'] as string ?? '' : '',
|
||||
description: meta !== undefined ? meta['description'] as string ?? '' : '',
|
||||
tools: tools,
|
||||
};
|
||||
rows.push(row);
|
||||
}
|
||||
// 列表整体重建(ForEach key 含 loaded/disabled,启停会整行重挂载):
|
||||
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.plugins = rows;
|
||||
});
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.load', e), true);
|
||||
}
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** 徽标状态:与 GUI 一致 —— 已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。 */
|
||||
private statusOf(plugin: PluginRow): string {
|
||||
if (plugin.loaded && !plugin.disabled) {
|
||||
return 'loaded'; // 已加载
|
||||
}
|
||||
if (plugin.loaded && plugin.disabled) {
|
||||
return 'pending'; // 运行中(禁用待生效)
|
||||
}
|
||||
if (plugin.disabled) {
|
||||
return 'disabled'; // 已禁用
|
||||
}
|
||||
return 'notloaded'; // 未加载
|
||||
}
|
||||
|
||||
private async togglePlugin(plugin: PluginRow): Promise<void> {
|
||||
const name: string = plugin.name;
|
||||
const action: string = plugin.disabled ? 'enable' : 'disable';
|
||||
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 = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /plugins/{name} —— 后端返回插件清单字段。
|
||||
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
|
||||
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
|
||||
*/
|
||||
private async loadDetail(name: string): Promise<void> {
|
||||
this.detailLoading = true;
|
||||
this.detailError = '';
|
||||
try {
|
||||
const resp = await apiClient.getWithTimeout('/plugins/' + name, 10000);
|
||||
const o: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const d: PluginDetail = emptyPluginDetail();
|
||||
d.name = o['name'] as string ?? name;
|
||||
d.version = o['version'] as string ?? '';
|
||||
d.description = o['description'] as string ?? '';
|
||||
d.author = o['author'] as string ?? '';
|
||||
d.license = o['license'] as string ?? '';
|
||||
d.homepage = o['homepage'] as string ?? '';
|
||||
d.repository = o['repository'] as string ?? '';
|
||||
d.entry = o['entry'] as string ?? '';
|
||||
d.minVersion = o['min_version'] as string ?? '';
|
||||
d.deprecated = o['deprecated'] as boolean ?? false;
|
||||
d.tags = this.strArray(o['tags']);
|
||||
d.files = this.strArray(o['files']);
|
||||
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.detail = d;
|
||||
});
|
||||
} 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 strArray(raw: Object | undefined): string[] {
|
||||
const out: string[] = [];
|
||||
if (raw === undefined || raw === null) {
|
||||
return out;
|
||||
}
|
||||
const arr: Object[] = raw as Object[];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const s: string = arr[i] as string ?? '';
|
||||
if (s.length > 0) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private findRow(name: string): PluginRow | null {
|
||||
for (let i = 0; i < this.plugins.length; i++) {
|
||||
if (this.plugins[i].name === name) {
|
||||
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() {
|
||||
this.ListStates()
|
||||
this.PluginList()
|
||||
}
|
||||
.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) {
|
||||
this.InstallForm()
|
||||
}
|
||||
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;
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.Toast()
|
||||
}
|
||||
.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);
|
||||
},
|
||||
}) {
|
||||
this.DetailContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
|
||||
/** 安装表单:悬浮在安装按钮上方的一张玻璃卡(点悬浮按钮开合) */
|
||||
@Builder
|
||||
InstallForm() {
|
||||
Row() {
|
||||
TextInput({ placeholder: '.hmap 包下载 URL', text: this.installUrl })
|
||||
.layoutWeight(1)
|
||||
.height(36)
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted)
|
||||
.backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.border({ width: 1, color: this.palette().border })
|
||||
.onChange((v: string) => {
|
||||
this.installUrl = v;
|
||||
})
|
||||
Button('安装')
|
||||
.height(36)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor('#FFFFFF')
|
||||
.margin({ left: 6 })
|
||||
.onClick(() => {
|
||||
this.installPlugin();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(10)
|
||||
.margin({ bottom: 10 })
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
/** 加载中 / 未配置 / 空列表三种占位态 */
|
||||
@Builder
|
||||
ListStates() {
|
||||
if (this.loading && this.plugins.length === 0) {
|
||||
LoadingProgress()
|
||||
.width(32)
|
||||
.height(32)
|
||||
.color(this.palette().accent)
|
||||
.margin({ top: 40 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (!apiClient.hasConnection()) {
|
||||
Text(noConnectionMessage())
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(20)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (!this.loading && this.plugins.length === 0 && apiClient.hasConnection()) {
|
||||
Text('暂无已加载插件')
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(20)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一级列表:每个插件一张紧凑卡(名称 + 状态徽标 + 右尖角)。
|
||||
* 描述、工具清单、启停/卸载操作全部下沉到详情页 —— 列表只负责选择。
|
||||
*/
|
||||
@Builder
|
||||
PluginList() {
|
||||
ForEach(this.plugins, (plugin: PluginRow) => {
|
||||
// 外层 Column 只为承载 transition:.transition() 不能直接挂在自定义组件
|
||||
// 调用点上(会生成 __Common__ 包装节点)。按压缩放由 MotionBase 统一提供,
|
||||
// 每行自带独立按压态,不再需要 pressedName 这种"哪一行被按"的手工记账。
|
||||
Column() {
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Row() {
|
||||
Column({ space: 3 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(plugin.name)
|
||||
.fontSize(15)
|
||||
.fontWeight(this.activeName === plugin.name ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
if (plugin.version !== undefined && plugin.version.length > 0) {
|
||||
Text('v' + plugin.version)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
|
||||
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
|
||||
Row({ space: 6 }) {
|
||||
Circle({ width: 6, height: 6 })
|
||||
.fill(this.statusDotColor(plugin))
|
||||
Text(this.rowSubtitle(plugin))
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.draggable(false)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.activeName === plugin.name
|
||||
? this.palette().accentBg : this.palette().bgCard)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().glassBorder,
|
||||
})
|
||||
// 选中态的底色/描边渐变:MotionBase 的 .animation() 到不了这里
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.openDetail(plugin.name);
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
// ForEach key 含 loaded/disabled:启停会整行重挂载,靠 transition 变成交叉淡入
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级页面:插件详情。
|
||||
* WebGUI 这里只有一个 JSON.stringify 的 <pre>,
|
||||
* 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 操作。
|
||||
*/
|
||||
@Builder
|
||||
DetailContent() {
|
||||
if (this.detailLoading) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(26)
|
||||
.height(26)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 30, bottom: 30 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (this.detailError.length > 0) {
|
||||
Text(this.detailError)
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.padding({ left: 4, bottom: 12 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
// 概览卡:名称、版本、状态徽标、描述
|
||||
PlainCard({ caption: '概览' }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.detail.name.length > 0 ? this.detail.name : this.activeName)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (this.detail.version.length > 0) {
|
||||
Text('v' + this.detail.version)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
this.DetailBadges()
|
||||
|
||||
if (this.detail.description.length > 0) {
|
||||
Text(this.detail.description)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.width('100%')
|
||||
.margin({ top: 10 })
|
||||
}
|
||||
}
|
||||
|
||||
// 清单卡:只有真拿到字段才出卡,否则会留一张空壳(内置插件没有清单文件)
|
||||
if (this.hasManifest()) {
|
||||
PlainCard({ caption: '清单' }) {
|
||||
this.KvRow('作者', this.detail.author)
|
||||
this.KvRow('许可证', this.detail.license)
|
||||
this.KvRow('主页', this.detail.homepage)
|
||||
this.KvRow('仓库', this.detail.repository)
|
||||
this.KvRow('入口', this.detail.entry)
|
||||
this.KvRow('最低内核版本', this.detail.minVersion)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.detail.tags.length > 0) {
|
||||
PlainCard({ caption: '标签' }) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.detail.tags, (t: string) => {
|
||||
Text(t)
|
||||
.fontSize(10)
|
||||
.fontColor('#4A90D9')
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().frostSoftBg)
|
||||
.margin({ right: 5, bottom: 5 })
|
||||
}, (t: string) => t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.DetailTools()
|
||||
|
||||
// 插件配置:plugin.<name>.* 从后端 /settings?prefix= 取,就地编辑。
|
||||
// 这些 key 属于插件本身,之前被平铺在「设置 → 后端配置」里,
|
||||
// 现在归位到插件详情页 —— 「插件的设计页面就是插件的详情页」。
|
||||
if (this.activeName.length > 0) {
|
||||
PlainCard({ caption: '插件配置' }) {
|
||||
SettingsEditor({
|
||||
prefix: 'plugin.' + this.activeName + '.',
|
||||
emptyHint: '该插件没有暴露可配置项',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.detail.files.length > 0) {
|
||||
PlainCard({ caption: '文件 (' + this.detail.files.length.toString() + ')' }) {
|
||||
ForEach(this.detail.files, (f: string) => {
|
||||
Text(f)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.width('100%')
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ bottom: 4 })
|
||||
}, (f: string) => f)
|
||||
}
|
||||
}
|
||||
|
||||
this.DetailActions()
|
||||
}
|
||||
|
||||
/** 详情页状态行:同样去掉徽标,一个状态点 + 一行纯文字 */
|
||||
@Builder
|
||||
DetailBadges() {
|
||||
Row({ space: 6 }) {
|
||||
Circle({ width: 7, height: 7 })
|
||||
.fill(this.activeStatusColor())
|
||||
Text(this.detailStatusLine())
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
private detailStatusLine(): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(this.activeStatusText());
|
||||
parts.push(this.activeIsBuiltin() ? '内置' : '外部');
|
||||
if (this.detail.deprecated) {
|
||||
parts.push('已废弃');
|
||||
}
|
||||
const n: number = this.activeTools().length;
|
||||
if (n > 0) {
|
||||
parts.push(n.toString() + ' 个工具');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** 工具清单来自一级列表已合并的 kernel.tools(按 plugin 归属) */
|
||||
@Builder
|
||||
DetailTools() {
|
||||
if (this.activeTools().length > 0) {
|
||||
PlainCard({ caption: '注册的工具 (' + this.activeTools().length.toString() + ')' }) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.activeTools(), (tool: string) => {
|
||||
Text(tool)
|
||||
.fontSize(11)
|
||||
.fontColor('#4A90D9')
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().frostSoftBg)
|
||||
.margin({ right: 5, bottom: 5 })
|
||||
}, (tool: string) => tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
DetailActions() {
|
||||
if (this.activeName.length > 0) {
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
Button(this.activeIsDisabled() ? '启用' : '禁用')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.activeIsDisabled()
|
||||
? this.palette().btnGhostBorder : 'rgba(217, 154, 43, 0.5)',
|
||||
})
|
||||
.fontColor(this.activeIsDisabled()
|
||||
? this.palette().textSecondary : '#D99A2B')
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
this.togglePlugin(r);
|
||||
}
|
||||
})
|
||||
Blank()
|
||||
if (!this.activeIsBuiltin()) {
|
||||
Button('卸载')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
|
||||
.fontColor(COLOR_ERROR)
|
||||
.onClick(() => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
this.removePlugin(r);
|
||||
}
|
||||
})
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 详情页取值助手:ArkTS 禁止非空断言,统一在这里做 null 收敛 ----
|
||||
|
||||
private hasManifest(): boolean {
|
||||
return this.detail.author.length > 0 || this.detail.license.length > 0 ||
|
||||
this.detail.homepage.length > 0 || this.detail.repository.length > 0 ||
|
||||
this.detail.entry.length > 0 || this.detail.minVersion.length > 0;
|
||||
}
|
||||
|
||||
private activeIsBuiltin(): boolean {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
return r !== null ? !r.external : false;
|
||||
}
|
||||
|
||||
private activeIsDisabled(): boolean {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
return r !== null ? r.disabled : false;
|
||||
}
|
||||
|
||||
private activeTools(): string[] {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r === null) {
|
||||
return [];
|
||||
}
|
||||
return r.tools ?? [];
|
||||
}
|
||||
|
||||
private activeStatusText(): string {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
return r !== null ? this.statusBadgeText(r) : '未加载';
|
||||
}
|
||||
|
||||
private activeStatusColor(): string {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
return r !== null ? this.statusBadgeColor(r) : this.palette().textMuted;
|
||||
}
|
||||
|
||||
/** 明细行:值为空时整行不渲染,避免详情页出现一排 "-" */
|
||||
@Builder
|
||||
KvRow(label: string, value: string) {
|
||||
if (value.length > 0) {
|
||||
Row() {
|
||||
Text(label)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
Text(value)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: 220 })
|
||||
.margin({ left: 16 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
Toast() {
|
||||
if (this.toastMsg.length > 0) {
|
||||
Row() {
|
||||
Text(this.toastMsg)
|
||||
.fontSize(13)
|
||||
.fontColor(this.toastIsError ? this.palette().toastErrorText : this.palette().toastText)
|
||||
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.toastIsError ? this.palette().toastErrorBg : this.palette().toastBg)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.toastIsError ? 'rgba(232, 64, 38, 0.3)' : 'rgba(23, 169, 100, 0.3)',
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.End)
|
||||
.padding({ right: 20 })
|
||||
.margin({ bottom: 166 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态点颜色:绿=已加载,黄=待生效,红=已禁用,灰=未加载 */
|
||||
private statusDotColor(plugin: PluginRow): string {
|
||||
return this.statusBadgeColor(plugin);
|
||||
}
|
||||
|
||||
/** 列表行副标题:状态 + 内置/外部 + 工具数,一行灰字,不用徽标 */
|
||||
private rowSubtitle(plugin: PluginRow): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(this.statusBadgeText(plugin));
|
||||
parts.push(plugin.external ? '外部' : '内置');
|
||||
if (plugin.tools !== undefined && plugin.tools.length > 0) {
|
||||
parts.push(plugin.tools.length.toString() + ' 工具');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
private statusBadgeText(plugin: PluginRow): string {
|
||||
const s: string = this.statusOf(plugin);
|
||||
if (s === 'loaded') {
|
||||
return '已加载';
|
||||
}
|
||||
if (s === 'pending') {
|
||||
return '待生效';
|
||||
}
|
||||
if (s === 'disabled') {
|
||||
return '已禁用';
|
||||
}
|
||||
return '未加载';
|
||||
}
|
||||
|
||||
private statusBadgeColor(plugin: PluginRow): string {
|
||||
const s: string = this.statusOf(plugin);
|
||||
if (s === 'loaded') {
|
||||
return '#17A964';
|
||||
}
|
||||
if (s === 'pending') {
|
||||
return '#D99A2B';
|
||||
}
|
||||
if (s === 'disabled') {
|
||||
return '#E84026';
|
||||
}
|
||||
return this.palette().textMuted;
|
||||
}
|
||||
}
|
||||
1456
cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets
Normal file
47
cmd/ohos/HomeAgent/entry/src/main/module.json5
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "entry",
|
||||
"type": "entry",
|
||||
"description": "HomeAgent client entry module",
|
||||
"mainElement": "EntryAbility",
|
||||
"deviceTypes": [
|
||||
"phone",
|
||||
"tablet"
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"installationFree": false,
|
||||
"pages": "$profile:main_pages",
|
||||
"requestPermissions": [
|
||||
{
|
||||
"name": "ohos.permission.INTERNET"
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.GET_NETWORK_INFO"
|
||||
}
|
||||
],
|
||||
"abilities": [
|
||||
{
|
||||
"name": "EntryAbility",
|
||||
"srcEntry": "./ets/entryability/EntryAbility.ets",
|
||||
"description": "HomeAgent main entry",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name",
|
||||
// 启动页图标用透明底字形,配合随主题切换的 start_window_background,
|
||||
// 浅色/深色模式下遮罩与图标都能对上,不再恒为深色样式。
|
||||
"startWindowIcon": "$media:start_icon",
|
||||
"startWindowBackground": "$color:start_window_background",
|
||||
"exported": true,
|
||||
"skills": [
|
||||
{
|
||||
"entities": [
|
||||
"entity.system.home"
|
||||
],
|
||||
"actions": [
|
||||
"action.system.home"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"color": [
|
||||
{
|
||||
"name": "start_window_background",
|
||||
"value": "#F1F3F5"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "HomeAgent"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
|
After Width: | Height: | Size: 177 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M11 21h-1l1-7H7.5c-.88 0-.33-.75-.31-.78C8.48 10.94 10.42 7.54 13.01 3h1l-1 7h3.51c.4 0 .62.19.4.66C12.97 17.55 11 21 11 21z"/></svg>
|
||||
|
After Width: | Height: | Size: 241 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M9 16.2l-3.5-3.5L4 14.2 9 19.2 20 8.2l-1.4-1.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 164 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>
|
||||
|
After Width: | Height: | Size: 162 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>
|
||||
|
After Width: | Height: | Size: 161 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
|
After Width: | Height: | Size: 218 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M4 6h18V4H4c-1.1 0-2 .9-2 2v11H0v3h14v-3H4V6zm19 2h-6c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h6c.55 0 1-.45 1-1V9c0-.55-.45-1-1-1zm-1 9h-4v-7h4v7z"/></svg>
|
||||
|
After Width: | Height: | Size: 258 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zm0 5v5m0 3v.5"/></svg>
|
||||
|
After Width: | Height: | Size: 220 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2.5L17.5 8H14V4.5zM8 13h8v1.6H8V13zm0 3.4h8V18H8v-1.6zM8 9.6h4v1.6H8V9.6z"/></svg>
|
||||
|
After Width: | Height: | Size: 256 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M19.35 10.04A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 0 0 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"/></svg>
|
||||
|
After Width: | Height: | Size: 266 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 14H4v-2.6l3.6-3.6 3 3 4.2-4.2L20 15v3zm0-5.3-5.2-5.2-4.2 4.2-3-3L4 13.2V6h16v6.7zM8.4 9.9a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||
|
After Width: | Height: | Size: 328 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zm0 2a7 7 0 0 0-5.3 11.5L17.5 6.7A7 7 0 0 0 12 5zm0 14a7 7 0 0 0 5.3-11.5L6.5 17.3A7 7 0 0 0 12 19z"/></svg>
|
||||
|
After Width: | Height: | Size: 251 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/></svg>
|
||||
|
After Width: | Height: | Size: 221 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M9 2v5H7V2h2zm8 0v5h-2V2h2zM5 9h14v3a7 7 0 0 1-6 6.9V22h-2v-3.1A7 7 0 0 1 5 12V9zm2 2v1a5 5 0 0 0 10 0v-1H7z"/></svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5z"/></svg>
|
||||
|
After Width: | Height: | Size: 151 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M3 12h3.3l2.1-5.4a1 1 0 0 1 1.9.1l2.6 9.1 1.9-4.4a1 1 0 0 1 .9-.6H21v2h-4.6l-2.6 6a1 1 0 0 1-1.9-.1L9.3 9.7 7.9 13.4a1 1 0 0 1-.9.6H3v-2z"/></svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
|
After Width: | Height: | Size: 318 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
|
||||
|
After Width: | Height: | Size: 332 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
||||
|
After Width: | Height: | Size: 154 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 2c.8 5.2 4.8 9.2 10 10-5.2.8-9.2 4.8-10 10-.8-5.2-4.8-9.2-10-10 5.2-.8 9.2-4.8 10-10z"/></svg>
|
||||
|
After Width: | Height: | Size: 206 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M7 7h10c.55 0 1 .45 1 1v8c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1V8c0-.55.45-1 1-1z"/></svg>
|
||||
|
After Width: | Height: | Size: 195 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12l-4-3-4 3V5h8v9z"/></svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.5 1h-8C6.12 1 5 2.12 5 3.5v17C5 21.88 6.12 23 7.5 23h8c1.38 0 2.5-1.12 2.5-2.5v-17C18 2.12 16.88 1 15.5 1zm-4.5 21c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5-4H7V4h9v14z"/></svg>
|
||||
|
After Width: | Height: | Size: 284 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>
|
||||
|
After Width: | Height: | Size: 371 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>
|
||||
|
After Width: | Height: | Size: 198 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM8 17H6v-6h2v6zm4 0h-2V9h2v8zm4 0h-2v-4h2v4z"/></svg>
|
||||
|
After Width: | Height: | Size: 199 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 22c5.52 0 10-4.48 10-10S17.52 2 12 2 2 6.48 2 12s4.48 10 10 10zm1-17.93c3.94.49 7 3.85 7 7.93s-3.05 7.44-7 7.93V4.07z"/></svg>
|
||||
|
After Width: | Height: | Size: 238 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.9 2.9-2.5-.6-.6-2.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 203 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>
|
||||
|
After Width: | Height: | Size: 236 B |
|
After Width: | Height: | Size: 9.5 KiB |
@ -0,0 +1,5 @@
|
||||
{
|
||||
"src": [
|
||||
"pages/Index"
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
{
|
||||
"network-security-config": {
|
||||
"domain-config": [
|
||||
{
|
||||
"cleartextTraffic": true,
|
||||
"domain": {
|
||||
"include-domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"color": [
|
||||
{
|
||||
"name": "start_window_background",
|
||||
"value": "#000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
cmd/ohos/HomeAgent/hvigor/hvigor-config.json5
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"modelVersion": "5.0.0",
|
||||
"dependencies": {
|
||||
"@ohos/hvigor": "file:/opt/huawei/command-line-tools/hvigor/hvigor",
|
||||
"@ohos/hvigor-ohos-plugin": "file:/opt/huawei/command-line-tools/hvigor/hvigor-ohos-plugin"
|
||||
}
|
||||
}
|
||||
1
cmd/ohos/HomeAgent/hvigorfile.ts
Normal file
@ -0,0 +1 @@
|
||||
export { appTasks } from '@ohos/hvigor-ohos-plugin';
|
||||