From 46e942f0c20ed21ed666468c21a812980a92a158 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 29 Aug 2026 10:24:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(ohos):=20=E9=B8=BF=E8=92=99=E7=AB=AF?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=8E=86=E5=8F=B2=E5=88=86=E6=AE=B5=E6=87=92?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=20+=20=E9=A6=96=E6=AC=A1=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E5=B7=A5=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原有 cmd/ohos/HomeAgent 是未入库的鸿蒙原生 ArkTS 工程,本次随改动一并入库, 保证他人 clone 后可直接编译(含 .gitignore 排除 build/oh_modules/签名材料, 提供 build-profile.json5.example 模板)。 本次功能改动(与 WebUI / GUI 三端对齐): - /chat/history 首屏只拉最新 CHAT_PAGE_SIZE(40) 条,1.26MB → 48.5KB - 抽出 parseHistoryPayload() 复用解析,记录 chatOffset/chatHasMore - 新增 loadOlderChat():向上滚动触顶(yOffset<60)懒加载更早页 - 工具调用 args/result 与 reasoning_content 完整还原,不做裁剪 构建验证:hvigorw assembleHap BUILD SUCCESSFUL(7.8s,ChatPage 零告警) --- cmd/ohos/.gitignore | 2 + cmd/ohos/HomeAgent/.gitignore | 19 + cmd/ohos/HomeAgent/AppScope/app.json5 | 10 + .../resources/base/element/string.json | 8 + .../resources/base/media/app_icon.png | Bin 0 -> 6032 bytes .../HomeAgent/build-profile.json5.example | 57 + cmd/ohos/HomeAgent/entry/hvigorfile.ts | 1 + .../HomeAgent/entry/oh-package-lock.json5 | 20 + cmd/ohos/HomeAgent/entry/oh-package.json5 | 12 + .../entry/src/main/ets/common/ApiClient.ets | 217 ++ .../entry/src/main/ets/common/BridgeCaps.ets | 195 ++ .../src/main/ets/common/BridgeRouter.ets | 99 + .../entry/src/main/ets/common/ConnStore.ets | 240 ++ .../entry/src/main/ets/common/Constants.ets | 328 +++ .../src/main/ets/common/DeviceBridge.ets | 394 ++++ .../src/main/ets/common/NavBarController.ets | 55 + .../src/main/ets/common/NavStackRegistry.ets | 51 + .../entry/src/main/ets/common/SseClient.ets | 258 +++ .../entry/src/main/ets/common/StatusStore.ets | 289 +++ .../entry/src/main/ets/common/UserError.ets | 178 ++ .../src/main/ets/components/Attachment.ets | 442 ++++ .../ets/components/GradientBackground.ets | 127 ++ .../src/main/ets/components/MarkdownView.ets | 155 ++ .../src/main/ets/components/PageTopBar.ets | 208 ++ .../main/ets/components/SettingsEditor.ets | 379 ++++ .../main/ets/components/StaticMarkdown.ets | 604 ++++++ .../src/main/ets/components/StatusCards.ets | 307 +++ .../entry/src/main/ets/components/SubPage.ets | 333 +++ .../main/ets/entryability/EntryAbility.ets | 140 ++ .../entry/src/main/ets/model/Model.ets | 175 ++ .../entry/src/main/ets/pages/ChatPage.ets | 1923 +++++++++++++++++ .../entry/src/main/ets/pages/DevicePage.ets | 652 ++++++ .../entry/src/main/ets/pages/Index.ets | 388 ++++ .../entry/src/main/ets/pages/PluginsPage.ets | 941 ++++++++ .../entry/src/main/ets/pages/SettingsPage.ets | 1370 ++++++++++++ .../HomeAgent/entry/src/main/module.json5 | 45 + .../main/resources/base/element/color.json | 8 + .../main/resources/base/element/string.json | 8 + .../main/resources/base/media/app_icon.png | Bin 0 -> 2684 bytes .../resources/base/media/ic_arrow_back.svg | 1 + .../src/main/resources/base/media/ic_bolt.svg | 1 + .../main/resources/base/media/ic_check.svg | 1 + .../resources/base/media/ic_chevron_down.svg | 1 + .../resources/base/media/ic_chevron_right.svg | 1 + .../main/resources/base/media/ic_close.svg | 1 + .../resources/base/media/ic_devices_multi.svg | 1 + .../main/resources/base/media/ic_error.svg | 1 + .../src/main/resources/base/media/ic_file.svg | 1 + .../main/resources/base/media/ic_gateway.svg | 1 + .../main/resources/base/media/ic_image.svg | 1 + .../src/main/resources/base/media/ic_link.svg | 1 + .../main/resources/base/media/ic_offline.svg | 1 + .../main/resources/base/media/ic_phone.svg | 1 + .../src/main/resources/base/media/ic_plug.svg | 1 + .../src/main/resources/base/media/ic_plus.svg | 1 + .../main/resources/base/media/ic_pulse.svg | 1 + .../main/resources/base/media/ic_refresh.svg | 1 + .../main/resources/base/media/ic_reload.svg | 1 + .../src/main/resources/base/media/ic_send.svg | 1 + .../main/resources/base/media/ic_sparkle.svg | 1 + .../src/main/resources/base/media/ic_stop.svg | 1 + .../main/resources/base/media/ic_tab_chat.svg | 1 + .../resources/base/media/ic_tab_devices.svg | 1 + .../resources/base/media/ic_tab_plugins.svg | 1 + .../resources/base/media/ic_tab_settings.svg | 1 + .../resources/base/media/ic_tab_status.svg | 1 + .../main/resources/base/media/ic_theme.svg | 1 + .../src/main/resources/base/media/ic_tool.svg | 1 + .../src/main/resources/base/media/ic_tune.svg | 1 + .../resources/base/profile/main_pages.json | 5 + .../base/profile/network_config.json | 14 + cmd/ohos/HomeAgent/hvigor/hvigor-config.json5 | 7 + cmd/ohos/HomeAgent/hvigorfile.ts | 1 + cmd/ohos/HomeAgent/oh-package-lock.json5 | 28 + cmd/ohos/HomeAgent/oh-package.json5 | 16 + cmd/ohos/README.md | 99 + 76 files changed, 10838 insertions(+) create mode 100644 cmd/ohos/.gitignore create mode 100644 cmd/ohos/HomeAgent/.gitignore create mode 100644 cmd/ohos/HomeAgent/AppScope/app.json5 create mode 100644 cmd/ohos/HomeAgent/AppScope/resources/base/element/string.json create mode 100644 cmd/ohos/HomeAgent/AppScope/resources/base/media/app_icon.png create mode 100644 cmd/ohos/HomeAgent/build-profile.json5.example create mode 100644 cmd/ohos/HomeAgent/entry/hvigorfile.ts create mode 100644 cmd/ohos/HomeAgent/entry/oh-package-lock.json5 create mode 100644 cmd/ohos/HomeAgent/entry/oh-package.json5 create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/ApiClient.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/ConnStore.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/Constants.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/NavBarController.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/NavStackRegistry.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/SseClient.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/UserError.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/GradientBackground.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/MarkdownView.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/PageTopBar.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsEditor.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/StaticMarkdown.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/entryability/EntryAbility.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/model/Model.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/pages/PluginsPage.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/module.json5 create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/element/color.json create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/element/string.json create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/app_icon.png create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_arrow_back.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_bolt.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_check.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_chevron_down.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_chevron_right.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_close.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_devices_multi.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_error.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_file.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_gateway.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_image.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_link.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_offline.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_phone.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_plug.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_plus.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_pulse.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_refresh.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_reload.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_send.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_sparkle.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_stop.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_chat.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_devices.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_plugins.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_settings.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_status.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_theme.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tool.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tune.svg create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/profile/main_pages.json create mode 100644 cmd/ohos/HomeAgent/entry/src/main/resources/base/profile/network_config.json create mode 100644 cmd/ohos/HomeAgent/hvigor/hvigor-config.json5 create mode 100644 cmd/ohos/HomeAgent/hvigorfile.ts create mode 100644 cmd/ohos/HomeAgent/oh-package-lock.json5 create mode 100644 cmd/ohos/HomeAgent/oh-package.json5 create mode 100644 cmd/ohos/README.md diff --git a/cmd/ohos/.gitignore b/cmd/ohos/.gitignore new file mode 100644 index 0000000..21c38c7 --- /dev/null +++ b/cmd/ohos/.gitignore @@ -0,0 +1,2 @@ +# 开发过程截图(体积大、非源码),不入库 +screenshots/ diff --git a/cmd/ohos/HomeAgent/.gitignore b/cmd/ohos/HomeAgent/.gitignore new file mode 100644 index 0000000..9842b8e --- /dev/null +++ b/cmd/ohos/HomeAgent/.gitignore @@ -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 diff --git a/cmd/ohos/HomeAgent/AppScope/app.json5 b/cmd/ohos/HomeAgent/AppScope/app.json5 new file mode 100644 index 0000000..5f89745 --- /dev/null +++ b/cmd/ohos/HomeAgent/AppScope/app.json5 @@ -0,0 +1,10 @@ +{ + "app": { + "bundleName": "com.example.homeagent", + "vendor": "HomeAgent", + "versionCode": 1000000, + "versionName": "1.0.0", + "icon": "$media:app_icon", + "label": "$string:app_name" + } +} diff --git a/cmd/ohos/HomeAgent/AppScope/resources/base/element/string.json b/cmd/ohos/HomeAgent/AppScope/resources/base/element/string.json new file mode 100644 index 0000000..72792ba --- /dev/null +++ b/cmd/ohos/HomeAgent/AppScope/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "app_name", + "value": "HomeAgent" + } + ] +} diff --git a/cmd/ohos/HomeAgent/AppScope/resources/base/media/app_icon.png b/cmd/ohos/HomeAgent/AppScope/resources/base/media/app_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0d77f2037aff83595db3d836dcaf98a99f26ceb9 GIT binary patch literal 6032 zcmV;B7jNi^P) zYmi;lb?1L;@AJ5~Z$I=F=s`1J+A@UKjKLO^JtpQ29z2$fYf{OSlN7GR!MHKDB)}3N)N1wP-gD00d(DS) zZudoMN!=~!-fpG;Ra@$N?z!ijz5i#ez4l&fuPvMas%k~VSAP#WxWT}U&iN}t(03YR z215ucB66bZtY8)*LI}ZGBmW}CJ{=i$0*{Kw0VvxX2qL0${(i25sw(BRB}}MpNWK3Z zL4Gr`=KS_Kg;zodr@u!33LqjRk(v4Ie0UAzJ0UV(OGTasP*o!$$29OUgms>n=k7CN zj9csq1AUHg20MoEf%U800ksTB4dEU`9hE{kB^UidEL5oM>;iJ8*YUQpF zk$7=8o#UV!r^8&qF$f{}G>L5pVJr{+#zvC-Q(K_ggjo^s&D`Bri|vlZk`)w;Kx9hg zd^8GQl6JPq>)iDbRJH7+7IDoG)YsF<=Fb13IW_t2p`oF}fJ$3OHgor1YO(#FizQ1H zYB6LEjxL>@IFqFLcTl|*)x^C2w8z%T+? z0bl%k#|y71Q#l--hVhOv(S!$o6!`iNJ#kc$yj-DxwZfqz(;PTBPSAk!9mNG)P+EDx zzWrk~vw}t2ZDl)Yz<)tiO?2?!!9md9@zrxI_<9PFg(FiiHl+;KzzUe7u4Dv;`ry^E z^7FEmE(H+s+?4~;<5Wf}b(j~PdKwdS;a!1<*sSmxS@~eID7Ksap`i~ZakR-5{*C@nMRoSGs#cS) z0+k1NIb+!pCero*D8`hoj=~4x$Ph=C=~j-aQcGeKC3v0j0C$qP+_0=vqKFWxMlK4^ zWHTr4aL>n)C9-9elGu{Q5vGb&a3PQv<+;sPPAe-)>0|zGlBIwpFJWb4mTGRgl@o$e zuf5uG;q$NpNmA2Hhi7OqfhRE8P@8if)xPbFYWWoC|bN*7tk|!)L9L@)7X-pDF zSo3yiXlu2j)m!Js^6%LLQKdE&@F9@pjzWZbEq>bzvJ|m22x~V^#YJ0170#FL^;(Po zk(JZZkgCu&Ka2?deJLRX6li4yab&O^QA9^QUQf|;J)jCUE=y_MK`Sq4Rt?wRmttp( zgfsOF+FmR`WDTv%p+PYs1TDiFV!Jc~hk8m_+4sQ2Tgt>+N}33T3xuH5lBkLW>8>eW zz9b0(G_!(w3~wG$-aMkzlir6o^h_mcfFy;NcL(-7SH{N$fTcYr#8u z)@i6h9KoAMEW7p!|LK~tnZEf%!3FD;^@Gx@_o=rkQRFydprz2eQ(2y!I&cX*SI;EO zYZZh*`0k*(j-E>rLhJE)(WkbY8o4U`Xl$kt1r7(lGqS}c5NZG4;qr#@cgUp zmB(ImFQlvM zpeP(k95G{RRrNL^QaI1%3!?Mx{Jd=V`E!1BU^V@l-eJzqaWK1^Q}{7$f}PdH>S70U7A8nc8&b!iglPl{@mH zT)omw6Tj;c)}97B5sVen%AKA!5)E$|VfcrCn?F2qwTx_jM>Kqlq-iLNP2qT!n*)3X zV#-p%d(~QE<=K73-kmS%=_|QYshQ2pw4Rgh8jIMZ=pflHsSmXCAwF`Xg5kqz4B4Z4V=ZTS}GWJQNP{!_c>} z94jEO_HxWMe}VqWe zdhPU)1wESS2)e)mgy4}h760s4=uci7h;F{m_`D$af?y&P0|?=t{{#;I4Bh_}`rMyG z@TgdVi3q;H=LPZ2_nCF}wE_3cS1@TRA^7qh3wS24vlee`Edz}jL;W=d8@1~1IT|L-@=H7?Vecy(3u(SmCeV_2s zH>q#?b#NKjsJs>de)R#B?_4Y4yj!TReHVEDHk1FPkTQu3B8U-+!eg!JFc$TUOzwA; zFjc4tJbF<1KYIf4aZ(%|4XcjvIOdbT_$qHDcX0pDH_2c&O)wFHBOq9s{K@FutTUEE z6}%&kV-7y^74DwifyZKSD1r$sj-;DpM{);iAO9+U^KZVvP|?E6{F~#NHNwZY7=kLb zG&*|H=8U0hgax2(;m1woiRV3&tujr-$)R{j==X23>F`eO+4um@ydLONA)rL+IAk0A z?%0<&ck)@Lq9Kfy+3IrJ$GMZwvi0ZxgRigtJZtjKMo(_sxHQFO*9gu5zX1{hCE17cMSI*-@U5STC#+w)zxsGpb9 zOV~(3z#u42QZ9C{ao6-c6sCsg>>Qt{r!Y0{n!bleSN#Tuk`45!M==ag@Up!G7xlB< z?_y`|Rt7>&m`83ITc!4VdQ7>iJwaRqk}kAn*Nu*Lo%}pIcMVDgY-{aePqdAev1ssR z1XBo167k8gKV?m^pUk94hmsYP%%rR-_H)w2f`8U1XH^nyOcC@YNB6XTtVzP4iK1pSYJPGf=6tx5KJq+BCRn z@|(Q7wTmfJ!%$8Ld*BVbg`se0(a#0tcq{IbQ?uuBiHn00u+&16PapbYMD>I=na9!> zruf~lFA%ge|4$zT9|FzRDI@?}0G0(|0*sqAZ1AsQ088MoO}S<2@3^e>6w|i9lA5_; z@l2Wl-qU=9_ctHnh^b=;7+`~cm2tCXQ51ImrdvU#-6u@SPFk2>L4;W0C+St3U%W_P za5PPwk^E&oHTDIjO@BGFAoiri6Z4rvf5aMpm_iczg6I6=MShZAMXcq$=KEY$pr~T@ ztufToGjAb6w+GGwW)dbG#PggC6%283Jd<2SElhGz_GA7zy_{{u>(t$0>R}Sk{H~?I z1+C}U*4o7n`mX2d>{)7Il4p{uh=Ze0Gq1kP0Ao2^F;s;(sA3i)qKiP+2s6N>hie@jbuW7czsfJ7YxoEES*~b4&a`P@II*uoG1S5| zFVx=6&jvogx>)$|p?i6*?>eR%>sen+opj*b<|F_&4;y}MtHA{!&kLf+kj7`(I1I|! zpHE$2xbqydfK1y4wSoBX@00%EHm=)tx7x-=$-)w|^Si7~p$KITNMnR@tAMjjB{Dmf8H`zg8Zn4E!iGz5+dj>jCqFLS z{x}qsTVuX+fErK(5~rAlKPt^_pQdlaC72>d7npgo>8i+OlzJMMi#EDoc=W_dGy+Z+ zRthXWYpJ>74{2#M(fZy;f{Rmflal*D9w1jUcL|i|m=qtUOg!|_u#b%s=88YWWtkS( z1wW6ej3@8Ispa%tXxnw=E^}bM*4Ru@h|`)F1^bTwyjzMRQ+)gVF!d^urbrwkD54$- zt}N=yV{we6DLVZs_S@HT#JQ=zz3l$oi3aLvDvNoZb}e$}yBc!8EXH9PoJNtMIIwH4 z+x7W}?Z_wIXFv4FK-h8{`rR7{PyPYC^=#=rNfJoYvLs1)?5$_ZbG~x}`Ii5{##~9kL>6O!r5H@cIq{Bd~`Ml=f?YB?Irju*C>u;$Ht+N``?%teec@-;YJ$E zLSy+u2t>BK5x`5DFbz)QIFaJOuD!bVfzjmZKfeJ1S9rukLYBkoOECZbugfsEH;)Q) zTYV{V(I?&>=9c9cV>_I3Tk?_JwJeD_cGKfGhZz{q>nG=`gn)5x6M91;(2s8Zy)N;?L_Sa3zPoO2a2dAt&Nm?^^YEB|Hp;`kSMRP|mpDA=*!hBG&tN@#Eym5;L zA8Ki0S_gLR4Zpa5G=1-vO3BJA$?8NqnpYj<)gEdy)9BSdV!#|*)yNphU10mV{^9Lw z`-lJA3;Q4b*v5g8?d$r7r!p6!6GZ=tK)u%eMPgkwI+QY!C^>IFd|H6E;M4B{N=^xUi-_em=po8N@9e>2!WEYzFK_T)i-Av zJ+Kl5P|`HUdxu6bI&q-$9v8-Ws3cz`^lyEEeRL-xRLg@U31;%0!!|q0{RT zVFKPE{hJ7z@1WTIH;55h>pz4f8%cvhdpON0K(5NxzJ%LW)wt)y*HPd@tLtp-OK3Vz z64|b7_;xgUCt+er(zf12SamrfkTlLAZ)Suzs-+0!Dh4W(w#zVU&^<+|1uW6>EiB8d11_sa)^Im^0$Sm`V1mO0$`hCechT4y_D} zyfPTNZ?hhY-V+8Kt=T*C^r|Y;t)fgRREx33R5rk=x#TN50`Ik2Ea*sLTMdZym~)p3 zmIz_TqBBo#qn1$kP#Ob;$9Yc@+mgI?2hkp+&VKArbF+)@+GMy8Xl4#;gnAm&sKwCn z{Zhq}C9D(eNn^Ckf*nGcxzr|x=_=b~JLS2nwqdu%;Dh2rIfLKMW#7(rYK+iVN$gD4 z(J)5vSomiHHb>irp^qpiL`(4)_>x^MpL`|(=7Au4;GprD{ z%&kol2BERt-%NXk`W`1aP-2$MPgoQ99Fu_#UL0X2s~zrR_oE+`PZW;5*)E4RIV%`0zsQ@y8!Asw!z>{<3hs zFvcuO%7Q{Yf!}MvaP~zTx;=uc@F@E*{s@w{dHWmZ)a!m zRp3rLtM;iVF9Tv!zAV>W=R{Q_5pm6|__JCP{c$rZoH28(eme{CU%H0&s2C_Oa*OF+VBy+KnrK-VNgTS~e%vJTeysWC_ zomAC`NXWA6Qe&e>jWMgU+<9YU!KBS>8#xCl)plWL9gauIV(Y4^!59fiWTWZn>|>2a z?LJktBI2dvBr76*YAU}fN$o?CHLDk^9#^naA-EBBwQr_nds-4=B8{UYL3 zHEJ}HU3unj@*%ubPva;A4XSz;8RJV6sv1<))zjFjhC|KVf4m~gw#kAyq_!<-PTXX04^w~?hfcZX%yWHuydzI*Ig&aOw1owQY!0F+Qj8~ z{(iCXodO^9-oNuSEOq)<4G~n}r`FiVg5g^cJXCc{Z)^9X@&5t$E9N=1@3NBs0000< KMNUMnLSTa0KhXXF literal 0 HcmV?d00001 diff --git a/cmd/ohos/HomeAgent/build-profile.json5.example b/cmd/ohos/HomeAgent/build-profile.json5.example new file mode 100644 index 0000000..9baefd3 --- /dev/null +++ b/cmd/ohos/HomeAgent/build-profile.json5.example @@ -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', + ], + }, + ], + }, + ], +} diff --git a/cmd/ohos/HomeAgent/entry/hvigorfile.ts b/cmd/ohos/HomeAgent/entry/hvigorfile.ts new file mode 100644 index 0000000..dcb7475 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/hvigorfile.ts @@ -0,0 +1 @@ +export { hapTasks } from '@ohos/hvigor-ohos-plugin'; diff --git a/cmd/ohos/HomeAgent/entry/oh-package-lock.json5 b/cmd/ohos/HomeAgent/entry/oh-package-lock.json5 new file mode 100644 index 0000000..96541d5 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/oh-package-lock.json5 @@ -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" + } + } +} \ No newline at end of file diff --git a/cmd/ohos/HomeAgent/entry/oh-package.json5 b/cmd/ohos/HomeAgent/entry/oh-package.json5 new file mode 100644 index 0000000..a836839 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/oh-package.json5 @@ -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" + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ApiClient.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ApiClient.ets new file mode 100644 index 0000000..ddc22b2 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ApiClient.ets @@ -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/` 或 `/uploads/`(注意:不带 /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 { + 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 { + if (this.conn === null) { + throw new ApiError('未选择连接', -1, ''); + } + const url = this.buildUrl(path); + const httpRequest = http.createHttp(); + try { + const header: Record = 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 = JSON.parse(body) as Record; + 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 { + const headers: Record = {}; + 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 { + 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 = JSON.parse(body) as Record; + 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 { + return this.request(path, 'GET', '', DEFAULT_API_TIMEOUT); + } + + async getWithTimeout(path: string, timeoutMs: number): Promise { + return this.request(path, 'GET', '', timeoutMs); + } + + async post(path: string, bodyObj: object | null): Promise { + 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 { + const bodyStr = bodyObj === null ? '' : JSON.stringify(bodyObj); + return this.request(path, 'POST', bodyStr, timeoutMs); + } +} + +export const apiClient: ApiClient = new ApiClient(); diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets new file mode 100644 index 0000000..ecf0eef --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets @@ -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 { + 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 { + 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 { + // 说明: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 { + 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 { + try { + if (this.engine === null) { + const extra: Record = { + '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 { + 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; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets new file mode 100644 index 0000000..280d654 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets @@ -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 { + 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); +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ConnStore.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ConnStore.ets new file mode 100644 index 0000000..8b0a299 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ConnStore.ets @@ -0,0 +1,240 @@ +import { preferences } from '@kit.ArkData'; +import { Context } from '@kit.AbilityKit'; +import { deviceInfo } from '@kit.BasicServicesKit'; +import { ConnectionConfig, AppSettings, emptySettings, defaultConnection } 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 { + this.prefs = await preferences.getPreferences(context, PREF_NAME); + await this.load(); + } + + private async load(): Promise { + 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 = []; + } + } + 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(); + } + } + } + + // 首次启动时预置默认连接(用户后端),避免所有页面空白 + async ensureDefaultConnection(): Promise { + await this.load(); + if (this.connections.length > 0) { + return; + } + const conn: ConnectionConfig = defaultConnection(); + conn.id = 'default'; + this.connections.push(conn); + this.settings.currentConnId = conn.id; + await this.save(); + } + + 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 { + const conn: ConnectionConfig = defaultConnection(); + conn.id = Date.now().toString(36) + Math.floor(Math.random() * 10000).toString(36); + conn.name = name; + conn.url = 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 { + for (let i = 0; i < this.connections.length; i++) { + const c: ConnectionConfig = this.connections[i]; + if (c.id === id) { + c.name = name; + c.url = url; + c.apiKey = apiKey; + break; + } + } + await this.save(); + } + + async deleteConnection(id: string): Promise { + 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 { + this.settings.currentConnId = id; + await this.save(); + } + + getSettings(): AppSettings { + return this.settings; + } + + async saveSettings(s: AppSettings): Promise { + 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/ 并往 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 { + 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(); diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/Constants.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/Constants.ets new file mode 100644 index 0000000..9049d71 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/Constants.ets @@ -0,0 +1,328 @@ +/** + * 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; + +/** 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(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(KEY_THEME_IS_DARK) ?? true; + return dark ? DARK_PALETTE : LIGHT_PALETTE; +} + +/** Raw read for non-UI code. */ +export function themeIsDark(): boolean { + return AppStorage.get(KEY_THEME_IS_DARK) ?? true; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets new file mode 100644 index 0000000..8d261ba --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets @@ -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; + +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 { + 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 { + // 每次连接使用新的 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=,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 { + const h: Record = {}; + 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; + try { + obj = JSON.parse(text) as Record; + } 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 = { 'detail': detail }; + const msg: Record = { + 'op': 'event', + 'device_id': this.deviceId, + 'type': eventType, + 'payload': payload, + }; + this.send(JSON.stringify(msg)); + } + + sendStatus(status: string): void { + const msg: Record = { + '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 []; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/NavBarController.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/NavBarController.ets new file mode 100644 index 0000000..7b4ac8d --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/NavBarController.ets @@ -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('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); + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/NavStackRegistry.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/NavStackRegistry.ets new file mode 100644 index 0000000..637b1b6 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/NavStackRegistry.ets @@ -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 = new Map(); + +/** 页面 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; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/SseClient.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/SseClient.ets new file mode 100644 index 0000000..b82c157 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/SseClient.ets @@ -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 { + 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 = { + '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 = header as Record; + 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(); + } + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets new file mode 100644 index 0000000..f37563e --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets @@ -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(K_UP, false); + AppStorage.setOrCreate(K_VERSION, '-'); + AppStorage.setOrCreate(K_STARTED, ''); + AppStorage.setOrCreate(K_AGENTS, 0); + AppStorage.setOrCreate(K_PLUGINS, 0); + AppStorage.setOrCreate(K_TOOLS, 0); + AppStorage.setOrCreate(K_ERR, ''); + AppStorage.setOrCreate(K_LOADING, false); + AppStorage.setOrCreate(K_REV, 0); + } + + getGroups(): StatGroup[] { + return this.groups; + } + + isUp(): boolean { + return AppStorage.get(K_UP) ?? false; + } + + async refresh(): Promise { + if (!apiClient.hasConnection()) { + this.fail(noConnectionMessage()); + return; + } + AppStorage.setOrCreate(K_LOADING, true); + AppStorage.setOrCreate(K_ERR, ''); + try { + const resp = await apiClient.getWithTimeout('/status', 8000); + const obj: Record = JSON.parse(resp.body) as Record; + + 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(K_UP, true); + AppStorage.setOrCreate(K_VERSION, version); + AppStorage.setOrCreate(K_AGENTS, agents); + AppStorage.setOrCreate(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(K_LOADING, false); + } + + /** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */ + private async collectKernel(groups: StatGroup[]): Promise { + try { + const kResp = await apiClient.getWithTimeout('/kernel', 8000); + const k: Record = JSON.parse(kResp.body) as Record; + + 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(K_PLUGINS, pluginCount); + AppStorage.setOrCreate(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 | undefined = k['llm'] as Record; + 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 | undefined = k['memory'] as Record; + 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 | undefined = k['documents'] as Record; + 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 | undefined = k['text_memory'] as Record; + 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 | undefined = k['knowledge'] as Record; + 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 | undefined = k['runtime'] as Record; + 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 = toolsArr[i] as Record; + 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(K_ERR, msg); + AppStorage.setOrCreate(K_UP, false); + AppStorage.setOrCreate(K_LOADING, false); + this.groups = []; + this.bump(); + } + + /** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */ + private bump(): void { + const cur: number = AppStorage.get(K_REV) ?? 0; + AppStorage.setOrCreate(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(); +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/UserError.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/UserError.ets new file mode 100644 index 0000000..f3e5ff3 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/UserError.ets @@ -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; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets new file mode 100644 index 0000000..cc8c4df --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets @@ -0,0 +1,442 @@ +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 } from '../common/Constants'; +import { COLOR_ERROR } from '../common/Constants'; +import { PlainCard } from './SubPage'; + +/** + * 附件解析与展示。 + * + * 后端 Attachment 只有四个字段:type / url / size / name + * (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。 + * 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。 + * + * 字节走 GET /files/ 或 /uploads/(注意不带 /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 = raw as Record; + 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 { + const pm: image.PixelMap | undefined = await loadPixelMap(this.att.url); + if (pm === undefined) { + this.failed = true; + return; + } + this.pixel = pm; + } + + build() { + 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) + } 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) + } 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) + } + } 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 { + 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.loading = false; + } + + /** 保存到应用沙箱 files 目录(不申请媒体库权限,避免为了看一张图要授权相册)。 */ + private async saveToSandbox(): Promise { + 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 }) + } + + // 预览:图片铺满宽度可缩放查看;文件给一个大图标占位 + 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) + } else if (this.pixel !== undefined) { + Image(this.pixel) + .width('100%') + .constraintSize({ maxHeight: 420 }) + .objectFit(ImageFit.Contain) + .borderRadius(RADIUS_MD) + .draggable(false) + } + } 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 }) + } + } + } + .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) + } + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } +} + +/** 下载并解码成 PixelMap;任何一步失败都返回 undefined(调用方显示占位)。 */ +async function loadPixelMap(url: string): Promise { + 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; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/GradientBackground.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/GradientBackground.ets new file mode 100644 index 0000000..7bdfebb --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/GradientBackground.ets @@ -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 { + 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; + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/MarkdownView.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/MarkdownView.ets new file mode 100644 index 0000000..18e582b --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/MarkdownView.ets @@ -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) + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/PageTopBar.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/PageTopBar.ets new file mode 100644 index 0000000..9f48c65 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/PageTopBar.ets @@ -0,0 +1,208 @@ +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants'; + +/** + * 顶栏高度(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() { + Button() { + Image(this.icon) + .width(18) + .height(18) + .fillColor(this.accent ? this.palette().accent : this.palette().textSecondary) + } + .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: 400, curve: Curve.EaseOut }) + } +} \ No newline at end of file diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsEditor.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsEditor.ets new file mode 100644 index 0000000..f21976c --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsEditor.ets @@ -0,0 +1,379 @@ +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'; + +/** + * 可复用的后端配置编辑器(按 key 前缀取一段配置并就地编辑)。 + * + * 抽出来的原因:配置项不应该全部堆在「设置 → 后端配置」一个平铺列表里。 + * - core.* 各分类归到「核心」下一级; + * - plugin..* 属于插件本身,放进该插件的详情页 —— 就是这个组件的用处。 + * + * 后端接口: + * - GET /settings?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 = {}; + + 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 { + 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 = JSON.parse(resp.body) as Record; + + const metaStore: Record = {}; + const rawMeta: Object | undefined = obj['meta']; + if (rawMeta !== undefined && rawMeta !== null) { + const mObj: Record = rawMeta as Record; + for (const mk of Object.keys(mObj)) { + const item: Record = mObj[mk] as Record; + 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..' + 完整 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 = rawVals as Record; + 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 { + 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 }) + } + + if (this.err.length > 0) { + Text(this.err) + .fontSize(12) + .fontColor(COLOR_ERROR) + .width('100%') + } + + if (!this.loading && this.err.length === 0 && this.entries.length === 0) { + Text(this.emptyHint) + .fontSize(12) + .fontColor(this.palette().textMuted) + .width('100%') + } + + 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 }) + } + } + .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) + 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) + .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) + .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; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/StaticMarkdown.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/StaticMarkdown.ets new file mode 100644 index 0000000..ac18949 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/StaticMarkdown.ets @@ -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) + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets new file mode 100644 index 0000000..3bf9b70 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets @@ -0,0 +1,307 @@ +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM } from '../common/Constants'; +import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants'; +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() { + 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) + } 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 }) + } 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) + .draggable(false) + } + .width(48) + .height(48) + .borderRadius(RADIUS_SM) + .backgroundColor(this.up ? 'rgba(23, 169, 100, 0.16)' : this.palette().bgHover) + + Column({ space: 3 }) { + Text(this.up ? '运行中' : '离线') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor(this.up ? COLOR_SUCCESS : this.palette().textMuted) + 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) + + // 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具) + 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 }) + } + + 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 }) + } + + // 这里原本有一张"服务负载"卡,用两条容量条画插件/工具数量。 + // 后端并不存在"插件上限/工具上限"这种容量概念,分母是两者取大值, + // 于是 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 }) + }, (g: StatGroup) => g.title) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets new file mode 100644 index 0000000..fd1e140 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets @@ -0,0 +1,333 @@ +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG } from '../common/Constants'; +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(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 }) + .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) { + SubPageBackButton({ + onTap: () => { + const cb = this.onBack; + if (cb !== undefined) { + cb(); + } + }, + }) + if (this.showRefresh) { + FloatIconButton({ + icon: $r('app.media.ic_refresh'), + onTap: () => { + const cb = this.onRefresh; + if (cb !== undefined) { + cb(); + } + }, + }) + } + } + } + } + } + .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() { + 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) + .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) + .draggable(false) + } + .width('100%') + .padding({ left: 14, right: 14, top: 12, bottom: 12 }) + .alignItems(VerticalAlign.Center) + .backgroundColor(this.selected ? this.palette().accentBg : Color.Transparent) + .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() { + 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; + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/entryability/EntryAbility.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 0000000..bcc6163 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/entryability/EntryAbility.ets @@ -0,0 +1,140 @@ +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 () => { + await connStore.ensureDefaultConnection(); + 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 () => { + await connStore.ensureDefaultConnection(); + 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'); + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/model/Model.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/model/Model.ets new file mode 100644 index 0000000..b291562 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/model/Model.ets @@ -0,0 +1,175 @@ +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/' 等;用于区分设备/渠道消息 */ + 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 到
 里,这里改为结构化展示。
+ */
+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/、/uploads/ 或远程 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;
+}
+
+export function defaultConnection(): ConnectionConfig {
+  const c: ConnectionConfig = {
+    id: '',
+    name: 'HomeAgent',
+    url: 'http://192.168.2.60:8080',
+    apiKey: 'jinrui233719',
+    type: 'webui',
+  };
+  return c;
+}
diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets
new file mode 100644
index 0000000..312ee12
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets
@@ -0,0 +1,1923 @@
+import { apiClient, ApiError } from '../common/ApiClient';
+import { userMessage, isTimeout } from '../common/UserError';
+import { SseClient, SseEvent } from '../common/SseClient';
+import { connStore } from '../common/ConnStore';
+import { navBar } from '../common/NavBarController';
+import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
+import { ChatMessage, ToolCallInfo, HistoryMessage, ChatAttachment } from '../model/Model';
+import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
+import { RADIUS_MD, RADIUS_PILL, RADIUS_SM } from '../common/Constants';
+import { WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
+import { CHAT_PAGE_SIZE } from '../common/Constants';
+import { MarkdownView } from '../components/MarkdownView';
+import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
+import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
+import {
+  AttachmentCard, AttachmentDetailContent, parseAttachment, attachmentFromChannelOutput,
+  fileNameOf, formatBytes,
+} from '../components/Attachment';
+import { http } from '@kit.NetworkKit';
+import { hilog } from '@kit.PerformanceAnalysisKit';
+import { picker, fileIo } from '@kit.CoreFileKit';
+import { common } from '@kit.AbilityKit';
+import { MeasureOptions } from '@ohos.measure';
+
+const SSE_RECONNECT_MS: number = 5000;
+
+/** 输入框字号与内边距:文字测量必须和 TextArea 的实际排版参数一致 */
+const INPUT_FONT_SIZE: number = 14;
+const INPUT_INNER_PAD: number = 16;
+/** 单行态左右让位:左边加号 42+8,右边发送键 44+8 */
+const INPUT_LEFT_GAP: number = 50;
+const INPUT_RIGHT_GAP: number = 52;
+
+/** 二级页面标识:附件(图片/文件)详情 */
+const SUB_NONE: string = '';
+const SUB_ATTACHMENT: string = 'attachment';
+
+interface SendChatBody {
+  message: string;
+  client_msg_id: string;
+  /** 非空时后端编码 source = "webui/",agent 可见来源设备 */
+  device_id?: string;
+  device_name?: string;
+}
+
+/** 分页历史解析结果:消息列表 + 服务端分页元数据 */
+interface ParsedHistory {
+  msgs: ChatMessage[];
+  /** 本页首条在服务端全量历史中的下标,作为下次向上翻页的 before 游标 */
+  offset: number;
+  /** 服务端是否还有更早的历史 */
+  hasMore: boolean;
+}
+
+/**
+ * 由文件名后缀推断 Content-Type。
+ * 后端按 multipart 部件的 Content-Type 判定 image/file,
+ * 给错会让图片被当成普通文件(缩略图就没了)。
+ */
+function mimeOf(name: string, isImage: boolean): string {
+  const i: number = name.lastIndexOf('.');
+  const ext: string = i >= 0 ? name.substring(i + 1).toLowerCase() : '';
+  if (ext === 'png') {
+    return 'image/png';
+  }
+  if (ext === 'jpg' || ext === 'jpeg') {
+    return 'image/jpeg';
+  }
+  if (ext === 'webp') {
+    return 'image/webp';
+  }
+  if (ext === 'gif') {
+    return 'image/gif';
+  }
+  if (ext === 'bmp') {
+    return 'image/bmp';
+  }
+  if (ext === 'heic' || ext === 'heif') {
+    return 'image/heic';
+  }
+  if (isImage) {
+    return 'image/jpeg';
+  }
+  if (ext === 'pdf') {
+    return 'application/pdf';
+  }
+  if (ext === 'txt' || ext === 'log' || ext === 'md') {
+    return 'text/plain';
+  }
+  if (ext === 'json') {
+    return 'application/json';
+  }
+  return 'application/octet-stream';
+}
+
+@Component
+export struct ChatPage {
+  @StorageProp('themeIsDark') private isDark: boolean = true;
+  @StorageProp('currentTab') private currentTab: number = 0;
+  /** 宽屏:左边聊天流(含底部导航栏),右边附件详情 */
+  @StorageProp('isWideScreen') private isWide: boolean = false;
+  @State messages: ChatMessage[] = [];
+  @State inputText: string = '';
+  @State chatLoading: boolean = false;
+  @State chatStage: string = '';
+  @State connected: boolean = false;
+  /** 分页历史:当前已加载消息在服务端全量中的起始下标 */
+  private chatOffset: number = 0;
+  /** 是否还有更早历史可向上加载 */
+  private chatHasMore: boolean = false;
+  @StorageProp('navVisible') private navVisible: boolean = true;
+  @State newMsgIds: number[] = [];
+  @State inputMultiLine: boolean = false;
+  /** 当前在右栏/二级页展示的附件;未打开时为 undefined */
+  @State activeAtt: ChatAttachment | undefined = undefined;
+  @State activeSub: string = SUB_NONE;
+  /** 加号菜单是否展开 */
+  @State attachMenuOpen: boolean = false;
+  /** 待发送附件:沙箱内的本地副本路径 + 展示名 + 字节数 + 类型 */
+  @State pendingName: string = '';
+  @State pendingSize: number = 0;
+  @State pendingIsImage: boolean = false;
+  @State uploading: boolean = false;
+  private pendingPath: string = '';
+  private pendingMime: string = '';
+  /** 底部固定行的实测宽度:用于文字测量,判断是否需要换行 */
+  private inputRowWidth: number = 0;
+  private navStack: NavPathStack = new NavPathStack();
+  private sse: SseClient = new SseClient();
+  private scroller: Scroller = new Scroller();
+  private reconnectTimer: number = -1;
+  private refreshTimer: number = -1;
+  private autoScrolling: boolean = false;
+  private navHidden: boolean = false;
+  private nextMsgId: number = 1;
+  // SSE 正在为当前轮次推送内容时置 true,阻止 POST 响应重复创建消息
+  private sseActiveForTurn: boolean = false;
+
+  aboutToAppear(): void {
+    // 把本页导航栈登记给 Index:返回手势/三键返回按当前 Tab 精确派发到这里
+    registerNavStack(0, this.navStack, () => {
+      this.activeSub = SUB_NONE;
+      this.activeAtt = undefined;
+    });
+    const cur = connStore.getCurrentConnection();
+    if (cur !== null) {
+      this.loadHistory();
+      this.connectSSE();
+    }
+  }
+
+  aboutToDisappear(): void {
+    unregisterNavStack(0);
+    this.cancelReconnect();
+    this.sse.close();
+    if (this.refreshTimer >= 0) {
+      clearTimeout(this.refreshTimer);
+      this.refreshTimer = -1;
+    }
+  }
+
+  private allocId(): number {
+    return this.nextMsgId++;
+  }
+
+  private cancelReconnect(): void {
+    if (this.reconnectTimer >= 0) {
+      clearTimeout(this.reconnectTimer);
+      this.reconnectTimer = -1;
+    }
+  }
+
+  private connectSSE(): void {
+    const cur = connStore.getCurrentConnection();
+    if (cur === null) {
+      return;
+    }
+    this.sse.close();
+    this.sse.connect(cur, '/chat/events',
+      (ev: SseEvent) => {
+        this.handleSSE(ev);
+      },
+      () => {
+        this.connected = false;
+        this.scheduleReconnect();
+      },
+      () => {
+        this.connected = true;
+      }).catch(() => {
+        this.connected = false;
+        this.scheduleReconnect();
+      });
+  }
+
+  private scheduleReconnect(): void {
+    if (this.reconnectTimer >= 0) {
+      return;
+    }
+    this.reconnectTimer = setTimeout(() => {
+      this.reconnectTimer = -1;
+      this.connectSSE();
+    }, SSE_RECONNECT_MS);
+  }
+
+  private async loadHistory(): Promise {
+    try {
+      // 分段懒加载:首屏只拉最新 CHAT_PAGE_SIZE 条,向上滚动触顶再拉更早的。
+      const resp = await apiClient.getWithTimeout('/chat/history?limit=' + CHAT_PAGE_SIZE, 8000);
+      const obj: Record = JSON.parse(resp.body) as Record;
+      const parsed: ParsedHistory = this.parseHistoryPayload(obj);
+      if (parsed.msgs.length === 0) {
+        return;
+      }
+      this.messages = parsed.msgs;
+      this.chatOffset = parsed.offset;
+      this.chatHasMore = parsed.hasMore;
+      this.scrollToBottom();
+    } catch (e) {
+      // ignore history load failure
+    }
+  }
+
+  /**
+   * 向上翻页:拉 offset 之前的更早一页,前置到 messages 头部并保持滚动位置。
+   * 触顶(yOffset 接近 0)且有更早历史时由 onDidScroll 触发。
+   */
+  private _loadingOlder: boolean = false;
+  private async loadOlderChat(): Promise {
+    if (this._loadingOlder || !this.chatHasMore) {
+      return;
+    }
+    this._loadingOlder = true;
+    try {
+      const before: number = this.chatOffset;
+      if (before <= 0) {
+        this.chatHasMore = false;
+        return;
+      }
+      const resp = await apiClient.getWithTimeout(
+        '/chat/history?limit=' + CHAT_PAGE_SIZE + '&before=' + before, 8000);
+      const obj: Record = JSON.parse(resp.body) as Record;
+      const parsed: ParsedHistory = this.parseHistoryPayload(obj);
+      if (parsed.msgs.length === 0) {
+        this.chatHasMore = false;
+        return;
+      }
+      // 前置插入新页(更早的在前),追加到当前列表头部;id 用新分配的避免与新消息撞号
+      const older: ChatMessage[] = parsed.msgs;
+      this.messages = older.concat(this.messages);
+      this.chatOffset = parsed.offset;
+      this.chatHasMore = parsed.hasMore;
+    } catch (e) {
+      // 失败保留 hasMore,允许下次滚动重试
+    } finally {
+      this._loadingOlder = false;
+    }
+  }
+
+  /** 解析后端 /chat/history 的响应体(含分页元数据),供首屏与翻页复用。 */
+  private parseHistoryPayload(obj: Record): ParsedHistory {
+    const rawList: Object | undefined = obj['messages'] as Object | undefined;
+    if (rawList === undefined || rawList === null) {
+      return { msgs: [], offset: 0, hasMore: false };
+    }
+    const arr: Object[] = rawList as Object[];
+    const msgs: ChatMessage[] = [];
+    for (let i = 0; i < arr.length; i++) {
+      const item: Record = arr[i] as Record;
+      const role: string = item['role'] as string ?? '';
+      const content: string = item['content'] as string ?? '';
+      const att: ChatAttachment | undefined = parseAttachment(item['attachment']);
+      // 纯附件消息 content 可能为空,不能再按"无内容就丢弃"处理
+      if (role.length === 0 || (content.length === 0 && att === undefined)) {
+        continue;
+      }
+      const msg: ChatMessage = {
+        id: this.allocId(),
+        role: role,
+        content: content,
+        isFinal: true,
+      };
+      if (att !== undefined) {
+        msg.attachment = att;
+      }
+      // 后端 handler.go 保证 history 不裁剪 reasoning_content / tool_calls,
+      // 这里必须还原,否则刷新后思考与工具卡就凭空消失。
+      const rc: string = item['reasoning_content'] as string ?? '';
+      if (rc.length > 0) {
+        msg.reasoningContent = rc;
+      }
+      const tcs: ToolCallInfo[] | undefined = this.parseHistoryToolCalls(item['tool_calls']);
+      if (tcs !== undefined) {
+        msg.toolCalls = tcs;
+      }
+      // 渠道/设备来源:后端 ChatMsg.source,用于区分 channel_output 等非 webui 消息
+      const src: string = item['source'] as string ?? '';
+      if (src.length > 0) {
+        msg.source = src;
+      }
+      msgs.push(msg);
+    }
+    const offset: number = typeof obj['offset'] === 'number' ? obj['offset'] as number : 0;
+    const hasMore: boolean = obj['has_more'] === true;
+    return { msgs: msgs, offset: offset, hasMore: hasMore };
+  }
+
+  /** 后端 tool_calls 条目带 tool 和 name 两份;args/result 可能是对象也可能是字符串。 */
+  private parseHistoryToolCalls(raw: Object | undefined): ToolCallInfo[] | undefined {
+    if (raw === undefined || raw === null) {
+      return undefined;
+    }
+    const arr: Object[] = raw as Object[];
+    if (arr.length === 0) {
+      return undefined;
+    }
+    const tcs: ToolCallInfo[] = [];
+    for (let i = 0; i < arr.length; i++) {
+      const item: Record = arr[i] as Record;
+      const name: string = (item['tool'] as string ?? '') || (item['name'] as string ?? '');
+      if (name.length === 0) {
+        continue;
+      }
+      const tc: ToolCallInfo = {
+        name: name,
+        args: this.stringifyField(item['args']),
+        result: this.stringifyField(item['result']),
+        status: item['status'] as string ?? undefined,
+        plugin: item['plugin'] as string ?? undefined,
+      };
+      tcs.push(tc);
+    }
+    return tcs.length > 0 ? tcs : undefined;
+  }
+
+  private handleSSE(ev: SseEvent): void {
+    try {
+      // 服务端 data 字段是完整 sdk.Event:{type, source, payload, timestamp}
+      // 业务字段全部在 payload 之下,历史实现直接读顶层导致流式/思考/工具调用全部失效。
+      const frame: Record = JSON.parse(ev.data) as Record;
+      const inner: Object | undefined = frame['payload'];
+      const payload: Record =
+        inner !== undefined && inner !== null ? inner as Record : frame;
+      const frameType: string = frame['type'] as string ?? '';
+      const type: string = ev.event.length > 0 ? ev.event : frameType;
+      // 诊断只记事件类型(内容可能含隐私,不落盘)
+      hilog.debug(0x0000, 'HomeAgent', 'sse %{public}s', type);
+
+      if (type === 'agent_output') {
+        const content: string = payload['content'] as string ?? '';
+        // channel_output 携带图片/文件:url/size/output_type 三个字段在 payload 顶层,
+        // 它是一条独立的附件消息,不能合并进上一条文本气泡。
+        const kind: string = payload['kind'] as string ?? '';
+        if (kind === 'channel_output') {
+          const att: ChatAttachment | undefined = attachmentFromChannelOutput(
+            payload['output_type'] as string ?? '',
+            payload['url'] as string ?? '',
+            payload['size'] as number ?? 0);
+          if (att !== undefined) {
+            const amsg: ChatMessage = {
+              id: this.allocId(),
+              role: 'assistant',
+              content: content,
+              isFinal: true,
+              source: 'channel',
+              attachment: att,
+            };
+            this.messages.push(amsg);
+            this.markNew(amsg.id);
+            this.chatLoading = false;
+            this.chatStage = '';
+            this.sseActiveForTurn = false;
+            this.refreshMessages();
+            this.scrollToBottom();
+            return;
+          }
+        }
+        const last: ChatMessage | null = this.lastMessage();
+        if (last !== null && last.role === 'assistant' && !last.isFinal) {
+          last.content = content;
+          last.isFinal = true;
+          last.isStreaming = false;
+        } else if (last !== null && last.role === 'assistant' && last.isFinal) {
+          // POST 已经创建了最终消息,仅合并内容(如果有增量)
+          if (content.length > last.content.length) {
+            last.content = content;
+          }
+        } else {
+          const msg: ChatMessage = {
+            id: this.allocId(),
+            role: 'assistant',
+            content: content,
+            isFinal: true,
+          };
+          this.messages.push(msg);
+          this.markNew(msg.id);
+        }
+        this.chatLoading = false;
+        this.chatStage = '';
+        this.sseActiveForTurn = false;
+        this.refreshMessages();
+        this.scrollToBottom();
+      } else if (type === 'reasoning') {
+        const rc: string = payload['content'] as string ?? '';
+        if (rc.length > 0) {
+          this.chatStage = 'AI 思考中...';
+          // 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条),
+          // 此时还没有"未完成的助手消息",必须新建一条,否则思考内容直接丢失。
+          let last: ChatMessage | null = this.lastMessage();
+          if (last === null || last.role !== 'assistant' || last.isFinal === true) {
+            const msg: ChatMessage = {
+              id: this.allocId(),
+              role: 'assistant',
+              content: '',
+              isStreaming: true,
+              isFinal: false,
+            };
+            this.messages.push(msg);
+            this.markNew(msg.id);
+            last = msg;
+          }
+          last.reasoningContent = rc;
+          this.refreshMessages();
+          this.scrollToBottom();
+        }
+      } else if (type === 'sync_required') {
+        // 断线重连时服务端要求补拉历史(ring 里没有可重放的聚合事件)
+        this.loadHistory();
+      } else if (type === 'agent_error') {
+        // 后端错误一律转人话,技术细节不上 UI
+        this.chatLoading = false;
+        this.chatStage = '本轮处理失败,请重试';
+        this.refreshMessages();
+      } else if (type === 'content_delta') {
+        const delta: string = payload['content'] as string ?? '';
+        if (delta.length > 0) {
+          this.sseActiveForTurn = true;
+          let last: ChatMessage | null = this.lastMessage();
+          if (last === null || last.role !== 'assistant' || last.isFinal) {
+            const msg: ChatMessage = {
+              id: this.allocId(),
+              role: 'assistant',
+              content: '',
+              isStreaming: true,
+              isFinal: false,
+            };
+            this.messages.push(msg);
+            this.markNew(msg.id);
+            last = msg;
+          }
+          last.content += delta;
+          this.refreshMessages();
+          this.scrollToBottom();
+        }
+      } else if (type === 'reasoning_delta') {
+        const delta: string = payload['content'] as string ?? '';
+        if (delta.length > 0) {
+          this.sseActiveForTurn = true;
+          this.chatStage = 'AI 思考中...';
+          let last: ChatMessage | null = this.lastMessage();
+          if (last === null || last.role !== 'assistant' || last.isFinal) {
+            const msg: ChatMessage = {
+              id: this.allocId(),
+              role: 'assistant',
+              content: '',
+              isStreaming: true,
+              isFinal: false,
+            };
+            this.messages.push(msg);
+            this.markNew(msg.id);
+            last = msg;
+          }
+          if (last.reasoningContent === undefined) {
+            last.reasoningContent = '';
+          }
+          last.reasoningContent += delta;
+          this.refreshMessages();
+        }
+      } else if (type === 'tool_call') {
+        const toolName: string = payload['tool'] as string ?? '';
+        const toolStatus: string = payload['status'] as string ?? '';
+        const toolPlugin: string = payload['plugin'] as string ?? '';
+        if (toolName.length > 0) {
+          this.chatStage = '工具调用: ' + toolName;
+          const target: ToolCallInfo = this.ensureToolCall(toolName);
+          if (toolPlugin.length > 0) {
+            target.plugin = toolPlugin;
+          }
+          const argsText: string = this.stringifyField(payload['args']);
+          if (argsText.length > 0) {
+            target.args = argsText;
+          }
+          if (toolStatus.length > 0) {
+            // 后端只在工具执行【结束】时发 tool_call(status=ok/denied/interrupted),
+            // 所以拿到 status 就意味着这次调用已收尾,result 一并落卡。
+            target.status = toolStatus;
+            target.result = this.stringifyField(payload['result']);
+          } else {
+            target.status = 'running';
+          }
+          this.refreshMessages();
+          this.scrollToBottom();
+        }
+      } else if (type === 'stage') {
+        const phase: string = payload['phase'] as string ?? '';
+        const channel: string = payload['channel'] as string ?? '';
+        const stageTool: string = payload['tool'] as string ?? '';
+        if (channel !== '_consolidation_') {
+          if (phase === 'pre_action') {
+            this.chatStage = 'AI 思考中...';
+          } else if (phase === 'before_toolcall') {
+            this.chatStage = '工具调用: ' + stageTool;
+            // 关键:tool_call 事件只在执行【结束】后才发,所以"调用中"这一态
+            // 必须由 before_toolcall 建卡,否则用户永远看不到工具正在跑。
+            if (stageTool.length > 0) {
+              const tc: ToolCallInfo = this.ensureToolCall(stageTool);
+              if (tc.status === undefined) {
+                tc.status = 'running';
+              }
+            }
+          } else if (phase === 'before_output') {
+            this.chatStage = '生成回复中...';
+          }
+          this.refreshMessages();
+        }
+      }
+    } catch (e) {
+      // ignore parse errors
+    }
+  }
+
+  /**
+   * 取当前助手消息里名为 name 的未完成工具卡,没有就建一张。
+   * 顺带保证一定存在一条"未定稿的助手消息"来挂这些卡。
+   */
+  private ensureToolCall(name: string): ToolCallInfo {
+    let last: ChatMessage | null = this.lastMessage();
+    if (last === null || last.role !== 'assistant' || last.isFinal === true) {
+      const msg: ChatMessage = {
+        id: this.allocId(),
+        role: 'assistant',
+        content: '',
+        isStreaming: true,
+        isFinal: false,
+      };
+      this.messages.push(msg);
+      this.markNew(msg.id);
+      last = msg;
+    }
+    if (last.toolCalls === undefined) {
+      last.toolCalls = [];
+    }
+    for (let i = 0; i < last.toolCalls.length; i++) {
+      const t: ToolCallInfo = last.toolCalls[i];
+      // 只复用"仍在执行"的同名卡:同一轮里同名工具被多次调用时,
+      // 已完成的那张不能被后来的调用覆盖。
+      if (t.name === name && this.tcRunning(t)) {
+        return t;
+      }
+    }
+    const created: ToolCallInfo = { name: name, args: '' };
+    last.toolCalls.push(created);
+    return created;
+  }
+
+  /** payload 字段可能是字符串、对象或数组,统一转成可展示文本。 */
+  private stringifyField(raw: Object | undefined): string {
+    if (raw === undefined || raw === null) {
+      return '';
+    }
+    if (typeof raw === 'string') {
+      return raw as string;
+    }
+    try {
+      return JSON.stringify(raw);
+    } catch (e) {
+      return String(raw);
+    }
+  }
+
+  private lastMessage(): ChatMessage | null {
+    if (this.messages.length === 0) {
+      return null;
+    }
+    return this.messages[this.messages.length - 1];
+  }
+
+  /** 防抖刷新:合并高频 SSE delta,最多 ~20fps */
+  private refreshMessages(): void {
+    if (this.refreshTimer >= 0) {
+      return;
+    }
+    this.refreshTimer = setTimeout(() => {
+      this.refreshTimer = -1;
+      this.messages = this.messages.slice();
+    }, 50);
+  }
+
+  /** 强制立即刷新(用于状态切换等需要即时响应的场景) */
+  private forceRefresh(): void {
+    if (this.refreshTimer >= 0) {
+      clearTimeout(this.refreshTimer);
+      this.refreshTimer = -1;
+    }
+    this.messages = this.messages.slice();
+  }
+
+  /**
+   * ForEach 键:消息结构变化即换键 → 旧气泡销毁重建 → @Builder 里的
+   * if 分支重新求值。这是 ArkUI V1 渲染模型决定的:ForEach 对相同键
+   * 只更新 @Prop/@Link 绑定,不重新执行 @Builder 体,所以
+   * 「思考卡/工具卡/附件」这些用 if 包裹的条件分支在首次渲染后
+   * 永远不会再次求值——气泡里的这些面板就永远不出现。
+   *
+   * 反过来,content_delta 不进 structSig:正文文本靠 MarkdownView
+   * 的 @Prop content 响应式更新,不重建气泡 → 流式渲染平滑。
+   * 实测 SSE 里 reasoning_delta 与 content_delta 不交错(思考阶段
+   * 先于输出阶段),所以思考期间重建气泡不会打断正文流式动画。
+   */
+  private structSig(msg: ChatMessage): string {
+    let s: string = msg.id.toString();
+    const rc: string | undefined = msg.reasoningContent;
+    s += '_r' + (rc !== undefined ? rc.length.toString() : '0');
+    s += '_ro' + (msg.reasoningOpen === true ? '1' : '0');
+    const tcs: ToolCallInfo[] | undefined = msg.toolCalls;
+    if (tcs !== undefined) {
+      s += '_t' + tcs.length.toString();
+      for (let i = 0; i < tcs.length; i++) {
+        const tc: ToolCallInfo = tcs[i];
+        s += '_' + (tc.status ?? '');
+        s += '_' + (tc.open === true ? 'o' : 'c');
+        s += '_' + (tc.args !== undefined ? tc.args.length.toString() : '0');
+        s += '_' + (tc.result !== undefined ? tc.result.length.toString() : '0');
+        s += '_' + (tc.plugin ?? '');
+      }
+    } else {
+      s += '_t0';
+    }
+    s += '_a' + (msg.attachment !== undefined ? '1' : '0');
+    s += '_src' + (msg.source ?? '');
+    s += '_f' + (msg.isFinal === true ? '1' : '0');
+    s += '_s' + (msg.isStreaming === true ? '1' : '0');
+    return s;
+  }
+
+  /**
+   * 消息列表底部留白:悬浮输入区(导航栏上方 94 + 输入行 44 ≈ 138)
+   * 之上再留 ~72 的安全间距,保证最后一条气泡的思考卡/工具卡/附件
+   * 不被遮挡;加号菜单与待发送附件预览展开时再加高。
+   */
+  private listBottomPad(): number {
+    let pad: number = 210;
+    if (this.inputMultiLine) {
+      pad += 60;
+    }
+    if (this.attachMenuOpen) {
+      pad += 70;
+    }
+    if (this.pendingName.length > 0) {
+      pad += 46;
+    }
+    return pad;
+  }
+
+  /** 标记新消息,触发入场动画 */
+  private markNew(msgId: number): void {
+    const arr: number[] = this.newMsgIds.slice();
+    arr.push(msgId);
+    this.newMsgIds = arr;
+    setTimeout(() => {
+      const idx: number = this.newMsgIds.indexOf(msgId);
+      if (idx >= 0) {
+        const updated: number[] = this.newMsgIds.slice();
+        updated.splice(idx, 1);
+        this.newMsgIds = updated;
+        this.forceRefresh();
+      }
+    }, 250);
+  }
+
+  private palette(): ThemePalette {
+    return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
+  }
+
+  /**
+   * 底部淡出遮罩的两个端色:背景底色的全不透明 / 全透明版本。
+   * bgPrimary 是 6 位十六进制,这里手拼 8 位 ARGB —— 与 PageTopBar
+   * 顶部淡出用的是同一手法,保证上下两端的融入观感一致。
+   */
+  private opaqueBottomBg(): string {
+    return '#FF' + this.palette().bgPrimary.substring(1);
+  }
+
+  private transparentBottomBg(): string {
+    return '#00' + this.palette().bgPrimary.substring(1);
+  }
+
+  /** 输入框底:高不透明度 + blur,保证背景内容不会透过输入文字 */
+  private inputSolidBg(): string {
+    return this.isDark ? 'rgba(28, 28, 30, 0.94)' : 'rgba(245, 245, 247, 0.92)';
+  }
+
+  private scrollToBottom(): void {
+    this.autoScrolling = true;
+    setTimeout(() => {
+      this.scroller.scrollEdge(Edge.Bottom);
+    }, 50);
+    setTimeout(() => {
+      this.autoScrolling = false;
+      this.navHidden = false;
+      navBar.setVisible(true);
+    }, 450);
+  }
+
+  /**
+   * 由【文本本身】判断输入框是否需要换行,而不是回读控件高度。
+   *
+   * 用 MeasureUtils 在单行态可用宽度下测量文字:宽度超了就是多行。
+   * 测量宽度恒定取单行态(窄)宽度,与控件当前实际宽度无关,
+   * 所以"多行时变宽"不会反过来改变判定结果 —— 没有反馈环,也就不抖。
+   */
+  private recomputeMultiLine(text: string): void {
+    const avail: number = this.inputRowWidth - INPUT_LEFT_GAP - INPUT_RIGHT_GAP
+      - INPUT_INNER_PAD * 2;
+    if (avail <= 0) {
+      return;
+    }
+    let multi: boolean = text.indexOf('\n') >= 0;
+    if (!multi && text.length > 0) {
+      const opt: MeasureOptions = {
+        textContent: text,
+        fontSize: INPUT_FONT_SIZE,
+      };
+      const size: SizeOptions = this.getUIContext().getMeasureUtils().measureTextSize(opt);
+      // measureTextSize 返回 px,可用宽度是 vp,换算后再比
+      const widthVp: number = this.getUIContext().px2vp(size.width as number);
+      multi = widthVp > avail;
+    }
+    if (multi !== this.inputMultiLine) {
+      this.getUIContext().animateTo({ duration: 260, curve: Curve.Friction }, () => {
+        this.inputMultiLine = multi;
+      });
+    }
+  }
+
+  // ===================== 附件:选择与上传 =====================
+
+  /** 从图库挑一张图 */
+  private async pickImage(): Promise {
+    this.attachMenuOpen = false;
+    try {
+      const options = new picker.PhotoSelectOptions();
+      options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
+      options.maxSelectNumber = 1;
+      const photoPicker = new picker.PhotoViewPicker();
+      const result = await photoPicker.select(options);
+      if (result.photoUris.length === 0) {
+        return;
+      }
+      this.stagePickedFile(result.photoUris[0], true);
+    } catch (e) {
+      this.chatStage = userMessage('chat.pickImage', e);
+      this.forceRefresh();
+    }
+  }
+
+  /** 从文件管理器挑一个文件 */
+  private async pickFile(): Promise {
+    this.attachMenuOpen = false;
+    try {
+      const options = new picker.DocumentSelectOptions();
+      options.maxSelectNumber = 1;
+      const docPicker = new picker.DocumentViewPicker();
+      const uris: string[] = await docPicker.select(options);
+      if (uris.length === 0) {
+        return;
+      }
+      this.stagePickedFile(uris[0], false);
+    } catch (e) {
+      this.chatStage = userMessage('chat.pickFile', e);
+      this.forceRefresh();
+    }
+  }
+
+  /**
+   * 把 picker 给的 URI 复制到应用沙箱。
+   * http 的 multiFormDataList.filePath 只能读应用自己的沙箱路径,
+   * 直接把 picker 的 media:// URI 交过去会读不到内容。
+   */
+  private stagePickedFile(srcUri: string, isImage: boolean): void {
+    try {
+      const ctx = getContext(this) as common.UIAbilityContext;
+      const name: string = fileNameOf(srcUri);
+      const destPath: string = ctx.filesDir + '/up_' + Date.now().toString(36) + '_' + name;
+      const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY);
+      const destFile = fileIo.openSync(destPath,
+        fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
+      fileIo.copyFileSync(srcFile.fd, destFile.fd);
+      fileIo.closeSync(srcFile);
+      fileIo.closeSync(destFile);
+      const stat = fileIo.statSync(destPath);
+      this.clearPendingFile();
+      this.pendingPath = destPath;
+      this.pendingName = name;
+      this.pendingSize = stat.size;
+      this.pendingIsImage = isImage;
+      this.pendingMime = mimeOf(name, isImage);
+    } catch (e) {
+      this.chatStage = userMessage('chat.stageFile', e);
+    }
+    this.forceRefresh();
+  }
+
+  /** 丢弃待发送附件,并删掉沙箱里的临时副本 */
+  private clearPendingFile(): void {
+    if (this.pendingPath.length > 0) {
+      try {
+        fileIo.unlinkSync(this.pendingPath);
+      } catch (e) {
+        // 已不存在,忽略
+      }
+    }
+    this.pendingPath = '';
+    this.pendingName = '';
+    this.pendingSize = 0;
+    this.pendingIsImage = false;
+    this.pendingMime = '';
+  }
+
+  /**
+   * 带附件发送:POST /chat/file(multipart),字段与 WebGUI 一致。
+   * 后端收下后自身会把用户消息与附件写进会话并触发 agent,
+   * 回复照常从 SSE 过来,所以这里不再走 /chat。
+   */
+  private async sendWithAttachment(text: string): Promise {
+    const path: string = this.pendingPath;
+    const name: string = this.pendingName;
+    const size: number = this.pendingSize;
+    const isImage: boolean = this.pendingIsImage;
+    const mime: string = this.pendingMime;
+
+    const att: ChatAttachment = {
+      type: isImage ? 'image' : 'file',
+      // 本地待上传:先用沙箱路径预览,上传成功后替换成服务端 URL
+      url: 'file://' + path,
+      size: size,
+      name: name,
+    };
+    const userMsg: ChatMessage = { id: this.allocId(), role: 'user', content: text };
+    userMsg.attachment = att;
+    this.messages.push(userMsg);
+    this.markNew(userMsg.id);
+    this.inputText = '';
+    this.inputMultiLine = false;
+    this.uploading = true;
+    this.chatLoading = true;
+    this.chatStage = '正在上传附件...';
+    this.sseActiveForTurn = false;
+    this.forceRefresh();
+    this.scrollToBottom();
+
+    const parts: http.MultiFormData[] = [
+      { name: 'file', contentType: mime, remoteFileName: name, filePath: path },
+      { name: 'message', contentType: 'text/plain', data: text },
+      { name: 'client_msg_id', contentType: 'text/plain', data: Date.now().toString(36) },
+      { name: 'device_id', contentType: 'text/plain', data: connStore.ensureDeviceId() },
+      { name: 'device_name', contentType: 'text/plain', data: connStore.getDeviceName() },
+    ];
+
+    try {
+      const resp = await apiClient.postMultipart('/chat/file', parts, 180000);
+      const obj: Record = JSON.parse(resp.body) as Record;
+      const uploaded: ChatAttachment | undefined = parseAttachment(obj['file']);
+      if (uploaded !== undefined) {
+        userMsg.attachment = uploaded;
+      }
+      this.chatStage = '等待 AI 回复...';
+    } catch (e) {
+      this.chatStage = userMessage('chat.upload', e);
+      this.chatLoading = false;
+    }
+    this.uploading = false;
+    this.clearPendingFile();
+    this.forceRefresh();
+    this.scrollToBottom();
+  }
+
+  private async sendChat(): Promise {
+    const text: string = this.inputText.trim();
+    if (this.chatLoading || this.uploading) {
+      return;
+    }
+    const cur = connStore.getCurrentConnection();
+    if (cur === null) {
+      return;
+    }
+    if (this.pendingPath.length > 0) {
+      await this.sendWithAttachment(text);
+      return;
+    }
+    if (text.length === 0) {
+      return;
+    }
+    this.inputText = '';
+    this.inputMultiLine = false;
+    // 重置 SSE 标记
+    this.sseActiveForTurn = false;
+
+    const userMsg: ChatMessage = { id: this.allocId(), role: 'user', content: text };
+    this.messages.push(userMsg);
+    this.markNew(userMsg.id);
+    this.chatLoading = true;
+    this.chatStage = '等待 AI 回复...';
+    this.forceRefresh();
+    this.scrollToBottom();
+
+    const bodyObj: SendChatBody = {
+      message: text,
+      client_msg_id: Date.now().toString(36),
+      // 必须带设备身份:后端没有 device_id 就把来源编码成 webui,
+      // agent 会以为消息来自网页端。device_id 非空时后端编码
+      // source = "webui/" 并注入设备上下文。
+      device_id: connStore.ensureDeviceId(),
+      device_name: connStore.getDeviceName(),
+    };
+
+    // 如果 SSE 已连接,POST 作为触发器(响应由 SSE 推送渲染);
+    // 仅在 SSE 未推送内容时才用 POST 响应兜底创建消息。
+    try {
+      const resp = await apiClient.postWithTimeout('/chat', bodyObj, 120000);
+
+      // SSE 已经处理了响应,跳过 POST 消息创建
+      if (this.sseActiveForTurn) {
+        this.chatLoading = false;
+        this.chatStage = '';
+        this.forceRefresh();
+        this.scrollToBottom();
+        return;
+      }
+
+      const parsed: Record = JSON.parse(resp.body) as Record;
+      const respText: string = parsed['response'] ?? '(无响应)';
+      const reasoning: string = parsed['reasoning_content'] ?? '';
+      const last: ChatMessage | null = this.lastMessage();
+      if (last !== null && last.role === 'assistant' && !last.isFinal) {
+        last.content = respText;
+        last.isFinal = true;
+        last.isStreaming = false;
+        if (reasoning.length > 0 && last.reasoningContent === undefined) {
+          last.reasoningContent = reasoning;
+        }
+      } else if (last !== null && last.role === 'assistant' && last.isFinal) {
+        // 已有最终消息,合并(不应发生,但防御性处理)
+        if (respText.length > last.content.length) {
+          last.content = respText;
+        }
+      } else {
+        const msg: ChatMessage = {
+          id: this.allocId(),
+          role: 'assistant',
+          content: respText,
+          isFinal: true,
+        };
+        if (reasoning.length > 0) {
+          msg.reasoningContent = reasoning;
+        }
+        this.messages.push(msg);
+        this.markNew(msg.id);
+      }
+      this.chatLoading = false;
+      this.chatStage = '';
+      this.forceRefresh();
+      this.scrollToBottom();
+    } catch (e) {
+      // 超时通常意味着后端仍在生成,不算失败;其余一律显示人话,
+      // 原始错误只进 hilog(之前把 e.message 拼进 chatStage 会把
+      // "Failed to connect to the server."、内网地址直接摆到聊天流里)。
+      if (isTimeout(e)) {
+        this.chatStage = '请求已发送,等待回复...';
+      } else {
+        this.chatStage = userMessage('chat.send', e);
+      }
+      this.forceRefresh();
+      this.scrollToBottom();
+    }
+  }
+
+  private async interruptChat(): Promise {
+    try {
+      await apiClient.post('/chat/interrupt', null);
+    } catch (e) {
+      // ignore
+    }
+  }
+
+  private toggleReasoning(msg: ChatMessage): void {
+    msg.reasoningOpen = !(msg.reasoningOpen === true);
+    this.forceRefresh();
+  }
+
+  /**
+   * 滚动回调:只设置普通标志位,仅在状态翻转时通知 navBar,
+   * 不在回调里做任何耗时操作。navBar.setVisible 内部已去重,
+   * 而布局(padding)不再依赖 navVisible,故翻转只触发 GPU 变换,
+   * 不会引起布局回流——这是滑动流畅的关键。
+   */
+  private handleScrollDirection(yOffset: number, state: ScrollState): void {
+    if (this.autoScrolling) {
+      return;
+    }
+    // 触顶(近顶部 60vp)且服务端还有更早历史 → 向上懒加载下一页
+    if (yOffset < 60 && this.chatHasMore) {
+      this.loadOlderChat();
+    }
+    if (state === ScrollState.Idle) {
+      if (this.navHidden) {
+        this.navHidden = false;
+        navBar.setVisible(true);
+      }
+    } else {
+      // Scroll / Fling:向下/惯性滚动时隐藏导航与输入栏
+      if (!this.navHidden) {
+        this.navHidden = true;
+        navBar.setVisible(false);
+      }
+    }
+  }
+
+  /** 判断消息是否处于入场动画阶段 */
+  private isNewMsg(msgId: number): boolean {
+    return this.newMsgIds.indexOf(msgId) >= 0;
+  }
+
+  // ===================== 二级页面:附件详情 =====================
+
+  /**
+   * 打开附件详情。
+   * WebGUI 点图片是新开浏览器标签、点文件是直接下载;
+   * 移动端没有"标签页",改成一个二级页面:大图预览 + 元信息 + 保存。
+   */
+  private openAttachment(att: ChatAttachment): void {
+    this.activeAtt = att;
+    this.activeSub = SUB_ATTACHMENT;
+    if (this.isWide && this.navStack.size() > 0) {
+      this.navStack.replacePathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), false);
+    } else {
+      this.navStack.pushPathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), true);
+    }
+  }
+
+  private closeAttachment(): void {
+    this.navStack.pop();
+    this.activeSub = SUB_NONE;
+    this.activeAtt = undefined;
+  }
+
+  /** 聊天流里最后一个带附件的消息:宽屏进入 Split 时用它填充右栏 */
+  private latestAttachment(): ChatAttachment | undefined {
+    for (let i = this.messages.length - 1; i >= 0; i--) {
+      const a: ChatAttachment | undefined = this.messages[i].attachment;
+      if (a !== undefined) {
+        return a;
+      }
+    }
+    return undefined;
+  }
+
+  build() {
+    // 宽屏:左栏聊天流(含底部导航与输入区),右栏附件详情。
+    // 窄屏:附件详情整屏覆盖,系统返回手势直接作用于 navStack。
+    Navigation(this.navStack) {
+      this.ChatBody()
+    }
+    .navDestination(this.SubDestination)
+    .mode(this.isWide ? NavigationMode.Split : NavigationMode.Stack)
+    .navBarPosition(NavBarPosition.Start)
+    .navBarWidth(WIDE_NAV_BAR_WIDTH)
+    .minContentWidth(WIDE_MIN_CONTENT)
+    .hideTitleBar(true)
+    .hideToolBar(true)
+    .hideBackButton(true)
+    .width('100%')
+    .height('100%')
+    .onNavBarStateChange((isVisible: boolean) => {
+      markSubPageOpen(this.isWide || !isVisible);
+    })
+    .onNavigationModeChange((mode: NavigationMode) => {
+      if (mode === NavigationMode.Split) {
+        markSubPageOpen(true);
+        // 右栏不能空白:有附件就展示最近一个,没有则由 SubDestination 显示空态
+        if (this.navStack.size() === 0) {
+          const a: ChatAttachment | undefined = this.latestAttachment();
+          this.activeAtt = a;
+          this.activeSub = SUB_ATTACHMENT;
+          this.navStack.pushPathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), false);
+        }
+      } else {
+        this.navStack.clear(false);
+        this.activeSub = SUB_NONE;
+        markSubPageOpen(false);
+      }
+    })
+  }
+
+  /** 附件详情二级页面 */
+  @Builder
+  SubDestination(name: string, param: object) {
+    NavDestination() {
+      if (name === SUB_ATTACHMENT) {
+        SubPageLayer({
+          title: '附件详情',
+          tab: 0,
+          onBack: () => {
+            this.closeAttachment();
+          },
+        }) {
+          this.AttachmentPane()
+        }
+      }
+    }
+    .hideTitleBar(true)
+    .backgroundColor(Color.Transparent)
+  }
+
+  @Builder
+  AttachmentPane() {
+    if (this.activeAtt !== undefined) {
+      AttachmentDetailContent({ att: this.activeAtt })
+    } else {
+      // 宽屏右栏空态:聊天里还没有任何图片/文件
+      Column({ space: 8 }) {
+        Image($r('app.media.ic_file'))
+          .width(38)
+          .height(38)
+          .fillColor(this.palette().textMuted)
+          .draggable(false)
+        Text('选择聊天中的图片或文件查看详情')
+          .fontSize(13)
+          .fontColor(this.palette().textMuted)
+      }
+      .width('100%')
+      .padding({ top: 60 })
+      .alignItems(HorizontalAlign.Center)
+    }
+  }
+
+  @Builder
+  ChatBody() {
+    Stack({ alignContent: Alignment.Bottom }) {
+      // 层1:消息列表(铺满全屏,内容从顶栏遮罩下方穿过时逐渐淡出)
+      Column() {
+        // Messages
+        Scroll(this.scroller) {
+          Column() {
+            ForEach(this.messages, (msg: ChatMessage, idx: number) => {
+              this.MessageBubble(msg)
+            }, (msg: ChatMessage, idx: number) => this.structSig(msg))
+
+            if (this.chatLoading) {
+              Row({ space: 8 }) {
+                LoadingProgress()
+                  .width(16)
+                  .height(16)
+                  .color(this.palette().accent)
+                Text(this.chatStage.length > 0 ? this.chatStage : '处理中...')
+                  .fontSize(12)
+                  .fontColor(this.palette().textMuted)
+              }
+              .width('100%')
+              .justifyContent(FlexAlign.Start)
+              .padding({ left: 52, top: 6, bottom: 6 })
+              .opacity(1)
+            }
+          }
+          .width('100%')
+          .padding({
+            left: 14,
+            right: 14,
+            top: 76,
+            bottom: this.listBottomPad()
+          })
+        }
+        .width('100%')
+        .height('100%')
+        .scrollBar(BarState.Off)
+        .edgeEffect(EdgeEffect.Spring)
+        .align(Alignment.Top)
+        .onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
+          this.handleScrollDirection(yOffset, state);
+        })
+      }
+      .width('100%')
+      .height('100%')
+
+      // 层1.5:顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None,触摸完全穿透)
+      PageTopBar({ title: '聊天' })
+
+      // 层1.6:底部淡出遮罩 —— 滚动内容接近悬浮输入区/底部导航时逐渐隐入背景,
+      // 而不是在玻璃后面清晰可见(PageTopBar 顶部淡出手法的镜像,方向相反)。
+      Column()
+        .width('100%')
+        .height(170)
+        .linearGradient({
+          direction: GradientDirection.Bottom,
+          colors: [
+            [this.transparentBottomBg(), 0.0],
+            [this.opaqueBottomBg(), 0.6],
+            [this.opaqueBottomBg(), 1.0],
+          ],
+        })
+        .hitTestBehavior(HitTestMode.None)
+
+      // 层2:悬浮输入区(导航栏上方,与导航栏左右平齐,各组件独立不共框)
+      NavFloatOverlay({ tab: 0 }) {
+        // 加号展开的两个选项(图片 / 文件),点一次收起
+        if (this.attachMenuOpen) {
+          this.AttachMenu()
+        }
+
+        // 待发送附件预览(选好图片/文件、还没点发送时显示)
+        if (this.pendingName.length > 0) {
+          this.PendingAttachmentChip()
+        }
+
+        // Stack 而不是 Column:加号与发送按钮钉死在底部这一行不动,
+        // 输入框是浮在它们上面的独立层,超过一行就往上长并展开到整行宽度。
+        Stack({ alignContent: Alignment.Bottom }) {
+          // 底层:固定不动的一行 —— 左加号(图片/文件)、右发送/中断按钮
+          Row() {
+            FloatIconButton({
+              icon: $r('app.media.ic_plus'),
+              onTap: () => {
+                this.attachMenuOpen = !this.attachMenuOpen;
+              },
+            })
+            Blank()
+            if (this.chatLoading) {
+              Button() {
+                Image($r('app.media.ic_stop'))
+                  .width(16)
+                  .height(16)
+                  .fillColor(Color.White)
+              }
+                .width(44)
+                .height(44)
+                .type(ButtonType.Circle)
+                .backgroundColor('#77809A')
+                .onClick(() => {
+                  this.interruptChat();
+                })
+            } else {
+              Button() {
+                Image($r('app.media.ic_send'))
+                  .width(18)
+                  .height(18)
+                  .fillColor(Color.White)
+              }
+                .width(44)
+                .height(44)
+                .type(ButtonType.Circle)
+                .backgroundColor(this.palette().accent)
+                .enabled(this.inputText.trim().length > 0 || this.pendingPath.length > 0)
+                .onClick(() => {
+                  this.sendChat();
+                })
+            }
+          }
+          .width('100%')
+          .height(44)
+          .alignItems(VerticalAlign.Center)
+          .onAreaChange((_o: Area, n: Area) => {
+            // 这一行高度恒为 44、宽度恒为 100%,测量它不会形成反馈环。
+            const w: number = n.width as number;
+            if (Math.abs(w - this.inputRowWidth) > 0.5) {
+              this.inputRowWidth = w;
+              this.recomputeMultiLine(this.inputText);
+            }
+          })
+
+          // 上层:输入框。
+          // 单行时左右让出加号(42+8)与发送键(44+8)的位置,与它们同处一行;
+          // 多行时整体上移 52 抬到那一行之上,并铺满整行宽度。
+          //
+          // 之前"只上移不变宽"是因为宽度被钉死了:让宽度跟着实测高度变会形成
+          // 布局反馈环(变宽→文字回落成一行→变窄→又折行),卡在半弹出态抖动。
+          // 现在改用 MeasureUtils 直接量文字:始终按【窄宽度】测量是否需要换行,
+          // 判定输入只依赖文本内容,与控件实际宽度无关,所以变宽也不会自激。
+          Row() {
+            TextArea({
+              placeholder: '输入消息...',
+              text: this.inputText,
+            })
+              .layoutWeight(1)
+              // 不写死高度:单行 44,随文字换行自动增高,最多约 5 行后内部滚动
+              .constraintSize({ minHeight: 44, maxHeight: 168 })
+              .fontSize(INPUT_FONT_SIZE)
+              .fontColor(this.palette().textPrimary)
+              .placeholderFont({ size: 13 })
+              .placeholderColor(this.palette().textMuted)
+              .backgroundColor(this.inputSolidBg())
+              .backdropBlur(24)
+              .borderRadius(22)
+              .border({ width: 1, color: this.palette().glassBorder })
+              .padding({
+                left: INPUT_INNER_PAD,
+                right: INPUT_INNER_PAD,
+                top: 11,
+                bottom: 11,
+              })
+              .enterKeyType(EnterKeyType.Send)
+              .onChange((value: string) => {
+                this.inputText = value;
+                this.recomputeMultiLine(value);
+              })
+              .onSubmit(() => {
+                this.sendChat();
+              })
+          }
+          .width('100%')
+          // 多行时必须显式写 0:给 .padding() 传 undefined 在增量更新时会被当作
+          // "不修改该属性",旧的左右 50/52 留在原地 —— 这就是"只上移不变宽"。
+          .padding(this.inputMultiLine
+            ? { left: 0, right: 0 }
+            : { left: INPUT_LEFT_GAP, right: INPUT_RIGHT_GAP })
+          .margin({ bottom: this.inputMultiLine ? 52 : 0 })
+          // 关键:这层 Row 铺满整宽,它的左右 padding 正好压在加号与发送键上方。
+          // 不设 None 的话 padding 区域仍属于 Row,会把点击吞掉 —— 发送键点不动。
+          // None = 自身不响应、子节点(TextArea)照常响应,触摸落到下层那一行。
+          .hitTestBehavior(HitTestMode.None)
+          .animation({ duration: 260, curve: Curve.Friction })
+        }
+        .width('100%')
+      }
+    }
+    .width('100%')
+    .height('100%')
+  }
+
+  /** 加号菜单:两枚独立的玻璃胶囊,和输入区其他组件同一套视觉语言 */
+  @Builder
+  AttachMenu() {
+    // 外层撑满并左对齐:菜单要出现在加号正上方,而不是跟着悬浮区右对齐
+    Row() {
+      Row({ space: 8 }) {
+        this.AttachOption($r('app.media.ic_image'), '图片', () => {
+          this.pickImage();
+        })
+        this.AttachOption($r('app.media.ic_file'), '文件', () => {
+          this.pickFile();
+        })
+      }
+    }
+    .width('100%')
+    .justifyContent(FlexAlign.Start)
+    .margin({ bottom: 8 })
+    .hitTestBehavior(HitTestMode.Transparent)
+  }
+
+  @Builder
+  AttachOption(icon: Resource, label: string, tap: () => void) {
+    Row({ space: 6 }) {
+      Image(icon)
+        .width(15)
+        .height(15)
+        .fillColor(this.palette().textSecondary)
+        .draggable(false)
+      Text(label)
+        .fontSize(12)
+        .fontColor(this.palette().textPrimary)
+    }
+    .padding({ left: 12, right: 14, top: 8, bottom: 8 })
+    .backgroundColor(this.palette().navBarBg)
+    .borderRadius(RADIUS_PILL)
+    .border({ width: 1, color: this.palette().navBarBorder })
+    .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
+    .onClick(tap)
+  }
+
+  /** 待发送附件预览条:缩略信息 + 一个移除按钮 */
+  @Builder
+  PendingAttachmentChip() {
+    Row({ space: 8 }) {
+      Image(this.pendingIsImage ? $r('app.media.ic_image') : $r('app.media.ic_file'))
+        .width(15)
+        .height(15)
+        .fillColor(this.palette().accent)
+        .draggable(false)
+      Column({ space: 1 }) {
+        Text(this.pendingName)
+          .fontSize(12)
+          .fontColor(this.palette().textPrimary)
+          .maxLines(1)
+          .textOverflow({ overflow: TextOverflow.Ellipsis })
+        Text(this.uploading ? '上传中...' : formatBytes(this.pendingSize))
+          .fontSize(10)
+          .fontColor(this.palette().textMuted)
+      }
+      .alignItems(HorizontalAlign.Start)
+      .layoutWeight(1)
+      if (this.uploading) {
+        LoadingProgress()
+          .width(14)
+          .height(14)
+          .color(this.palette().accent)
+      } else {
+        Image($r('app.media.ic_close'))
+          .width(13)
+          .height(13)
+          .fillColor(this.palette().textMuted)
+          .draggable(false)
+          .onClick(() => {
+            this.clearPendingFile();
+            this.forceRefresh();
+          })
+      }
+    }
+    .width('100%')
+    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
+    .margin({ bottom: 8 })
+    .backgroundColor(this.palette().navBarBg)
+    .borderRadius(RADIUS_MD)
+    .border({ width: 1, color: this.palette().navBarBorder })
+    .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
+  }
+
+  @Builder
+  Avatar(role: string) {    Text(role === 'user' ? '我' : 'AI')
+      .fontSize(11)
+      .fontWeight(FontWeight.Bold)
+      .fontColor(role === 'user' ? this.palette().msgUserText : this.palette().accent)
+      .textAlign(TextAlign.Center)
+      .width(28)
+      .height(28)
+      .borderRadius(RADIUS_PILL)
+      .backgroundColor(role === 'user' ? this.palette().msgUserBg : this.palette().accentBg)
+      .margin({ top: 2 })
+  }
+
+  @Builder
+  ReasoningCard(msg: ChatMessage) {
+    // 两层结构,原因见 BubbleBody 的布局说明:
+    // 外层 Row 是"外观壳"(虚线边框 / 底色 / 圆角),不设百分比宽度,
+    // 靠内层 layoutWeight(1) 把气泡内容框的剩余宽度吃满;
+    // 内层 holder Column 自身无 padding,所以它的子节点写 width('100%')
+    // 才有正确的解析基准,不会再溢出到气泡外被 clip 切掉。
+    Row() {
+      Column() {
+        Row({ space: 7 }) {
+        Image($r('app.media.ic_sparkle'))
+          .width(12)
+          .height(12)
+          .fillColor(this.palette().accent)
+        Text('思考过程')
+          .fontSize(11)
+          .fontWeight(FontWeight.Medium)
+          .fontColor(this.palette().textPrimary)
+        // 流式思考时给出明确进度指示,而不是一张看不出在动的折叠卡
+        if (msg.isFinal !== true && msg.isStreaming === true) {
+          LoadingProgress()
+            .width(11)
+            .height(11)
+            .color(this.palette().accent)
+        }
+        Blank()
+        Text(this.reasoningLenLabel(msg))
+          .fontSize(9.5)
+          .fontColor(this.palette().textMuted)
+        Image($r('app.media.ic_chevron_down'))
+          .width(14)
+          .height(14)
+          .fillColor(this.palette().textMuted)
+          .rotate({ angle: msg.reasoningOpen === true ? 180 : 0 })
+          .animation({ duration: 200, curve: Curve.EaseOut })
+      }
+      .width('100%')
+      .padding({ left: 10, right: 10, top: 6, bottom: 6 })
+      .onClick(() => {
+        this.toggleReasoning(msg);
+      })
+
+      if (msg.reasoningOpen === true) {
+        Text(msg.reasoningContent)
+          .fontSize(11.5)
+          .lineHeight(17)
+          .fontColor(this.palette().textTertiary)
+          .width('100%')
+          .padding({ left: 10, right: 10, bottom: 8 })
+          .maxLines(24)
+          .textOverflow({ overflow: TextOverflow.Ellipsis })
+          .wordBreak(WordBreak.BREAK_ALL)
+      }
+
+      // 流式思考中:扫光动画条(对齐 WebGUI reasoningSweep)
+      if (msg.isFinal !== true && msg.isStreaming === true) {
+        Stack() {
+          Row()
+            .height(2)
+            .borderRadius(2)
+            .width('200%')
+            .linearGradient({
+              angle: 90,
+              colors: [
+                ['rgba(255,255,255,0.01)', 0],
+                [this.palette().accent, 0.35],
+                ['rgba(255,255,255,0.01)', 0.5],
+                [this.palette().accent, 0.65],
+                ['rgba(255,255,255,0.01)', 1],
+              ],
+            })
+            .opacity(0.7)
+            .translate({ x: this.isNewMsg(msg.id) ? '0%' : '-50%' })
+            .animation({ duration: 1200, curve: Curve.Linear })
+        }
+        .width('100%')
+        .clip(true)
+        .height(2)
+        .margin({ top: 6 })
+      }
+      }
+      .layoutWeight(1)
+      .alignItems(HorizontalAlign.Start)
+    }
+    .alignItems(VerticalAlign.Top)
+    .margin({ bottom: 6 })
+    .borderRadius(RADIUS_SM)
+    .backgroundColor(this.palette().bgHover)
+    .border({ width: 1, color: this.palette().kvBorder, style: BorderStyle.Dashed })
+  }
+
+  /** 折叠时也要能看出思考在增长:显示字数 */
+  private reasoningLenLabel(msg: ChatMessage): string {
+    const rc: string | undefined = msg.reasoningContent;
+    if (rc === undefined || rc.length === 0) {
+      return '';
+    }
+    return rc.length.toString() + ' 字';
+  }
+
+  /**
+   * 是否仍在执行。
+   * 判据是 status 而不是 result:后端 status=ok 的工具也可能返回空串,
+   * 用 result 判断会让这类调用永远显示"调用中"。
+   */
+  private tcRunning(tc: ToolCallInfo): boolean {
+    const s: string | undefined = tc.status;
+    return s === undefined || s.length === 0 || s === 'running';
+  }
+
+  private tcError(tc: ToolCallInfo): boolean {
+    return tc.status === 'denied' || tc.status === 'error';
+  }
+
+  private tcLeftColor(tc: ToolCallInfo): string {
+    if (this.tcError(tc)) {
+      return '#DB3694';
+    }
+    if (this.tcRunning(tc)) {
+      return this.palette().accent;
+    }
+    return 'rgba(23, 169, 100, 0.8)';
+  }
+
+  @Builder
+  ToolCard(tc: ToolCallInfo) {
+    // 同 ReasoningCard:外层 Row 只做外观,内层 layoutWeight(1) 取真实内容宽
+    Row() {
+      Column() {
+        Row({ space: 6 }) {
+        if (this.tcError(tc)) {
+          Image($r('app.media.ic_error'))
+            .width(13).height(13)
+            .fillColor(this.tcIcoColor(tc))
+        } else if (this.tcRunning(tc)) {
+          LoadingProgress()
+            .width(12).height(12)
+            .color(this.tcIcoColor(tc))
+        } else {
+          Image($r('app.media.ic_check'))
+            .width(13).height(13)
+            .fillColor(this.tcIcoColor(tc))
+        }
+        Text(tc.name)
+          .fontSize(11)
+          .fontWeight(FontWeight.Medium)
+          .fontColor(this.palette().textPrimary)
+          .maxLines(1)
+          .textOverflow({ overflow: TextOverflow.Ellipsis })
+          // 名字可长可短,必须让它占剩余宽度并可收缩,
+          // 否则右侧状态文字会被挤出气泡(78% 宽度 + clip 直接切掉)
+          .layoutWeight(1)
+        if (tc.plugin !== undefined && tc.plugin.length > 0) {
+          Text(tc.plugin)
+            .fontSize(9.5)
+            .fontColor(this.palette().textMuted)
+            .maxLines(1)
+            .flexShrink(0)
+        }
+        // 状态徽标去掉,只留一行小字(用户要求"去掉所有状态徽标")
+        Text(this.tcStateLabel(tc))
+          .fontSize(9.5)
+          .fontWeight(FontWeight.Medium)
+          .fontColor(this.tcStateColor(tc))
+          .flexShrink(0)
+        Image($r('app.media.ic_chevron_down'))
+          .width(13).height(13)
+          .fillColor(this.palette().textMuted)
+          .flexShrink(0)
+          .rotate({ angle: tc.open === true ? 180 : 0 })
+          .animation({ duration: 150, curve: Curve.EaseOut })
+      }
+      .width('100%')
+      .alignItems(VerticalAlign.Center)
+      .onClick(() => {
+        this.toggleToolCard(tc);
+      })
+
+      if (tc.open === true) {
+        if (tc.args.length > 0 && tc.args !== '{}') {
+          Text('参数')
+            .fontSize(9.5).fontWeight(FontWeight.Medium)
+            .fontColor(this.palette().textMuted)
+            .margin({ top: 6, bottom: 2 })
+          Text(tc.args)
+            .fontSize(11)
+            .fontColor(this.palette().preText)
+            .backgroundColor(this.palette().preBg)
+            .borderRadius(4)
+            .padding({ left: 7, right: 7, top: 5, bottom: 5 })
+            .width('100%')
+            .textAlign(TextAlign.Start)
+            .wordBreak(WordBreak.BREAK_ALL)
+        }
+        if (tc.result !== undefined && tc.result.length > 0) {
+          Text('结果')
+            .fontSize(9.5).fontWeight(FontWeight.Medium)
+            .fontColor(this.palette().textMuted)
+            .margin({ top: 6, bottom: 2 })
+          Text(tc.result)
+            .fontSize(11)
+            .fontColor(this.palette().preText)
+            .backgroundColor(this.palette().preBg)
+            .borderRadius(4)
+            .padding({ left: 7, right: 7, top: 5, bottom: 5 })
+            .width('100%')
+            .textAlign(TextAlign.Start)
+            .wordBreak(WordBreak.BREAK_ALL)
+            .maxLines(8)
+            .textOverflow({ overflow: TextOverflow.Ellipsis })
+        }
+      }
+      }
+      .layoutWeight(1)
+      .alignItems(HorizontalAlign.Start)
+    }
+    .alignItems(VerticalAlign.Top)
+    .padding({ left: 10, right: 10, top: 7, bottom: 7 })
+    .margin({ bottom: 4 })
+    .borderRadius(RADIUS_SM)
+    .backgroundColor(this.palette().bgHover)
+    .border({
+      width: { left: 3, top: 1, right: 1, bottom: 1 },
+      color: {
+        left: this.tcLeftColor(tc),
+        top: this.palette().kvBorder,
+        right: this.palette().kvBorder,
+        bottom: this.palette().kvBorder,
+      },
+    })
+  }
+
+  private tcIcoColor(tc: ToolCallInfo): string {
+    if (this.tcError(tc)) {
+      return '#DB3694';
+    }
+    if (this.tcRunning(tc)) {
+      return this.palette().accent;
+    }
+    return 'rgba(23, 169, 100, 0.9)';
+  }
+
+  private tcStateLabel(tc: ToolCallInfo): string {
+    if (tc.status === 'denied') {
+      return '已拒绝';
+    }
+    if (this.tcRunning(tc)) {
+      return '调用中';
+    }
+    return '完成';
+  }
+
+  private tcStateColor(tc: ToolCallInfo): string {
+    if (tc.status === 'denied') {
+      return '#FF9EC6';
+    }
+    if (this.tcRunning(tc)) {
+      return '#A3B8FF';
+    }
+    return '#6EE7A8';
+  }
+
+  private tcStateBg(tc: ToolCallInfo): string {
+    if (tc.status === 'denied') {
+      return 'rgba(219, 54, 148, 0.18)';
+    }
+    if (this.tcRunning(tc)) {
+      return 'rgba(63, 110, 245, 0.18)';
+    }
+    return 'rgba(23, 169, 100, 0.18)';
+  }
+
+  /** 工具卡/思考卡在气泡内是独占一行的块,气泡 78% 宽度对它们太窄 —— 见 BubbleBody */
+
+  private toggleToolCard(tc: ToolCallInfo): void {
+    tc.open = !(tc.open === true);
+    this.forceRefresh();
+  }
+
+  /**
+   * 气泡最大宽度(相对 BubbleSlot 的宽度,即扣掉头像与间距后的真实可用宽)。
+   * 纯文本 78% 好看;但工具卡/思考卡是"面板",78% 会把里面的状态文字和
+   * 参数/结果压成一团(还会被 clip 切掉),所以带卡片时放宽到 92%。
+   */
+  private bubbleMaxWidth(msg: ChatMessage): string {
+    const hasPanels: boolean =
+      (msg.toolCalls !== undefined && msg.toolCalls.length > 0) ||
+      (msg.reasoningContent !== undefined && msg.reasoningContent.length > 0);
+    return hasPanels ? '92%' : '78%';
+  }
+
+  /**
+   * 是否"别处来的"消息。对齐 GUI 的 source !== 'webui' 判定,但多减一项:
+   * 本机自己发的消息在后端会被写成 webui/,那仍然是"我发的",
+   * 不能当成渠道消息挂上别人的头像。
+   */
+  private isChannelMsg(msg: ChatMessage): boolean {
+    const src: string = msg.source ?? '';
+    if (src.length === 0 || src === 'webui') {
+      return false;
+    }
+    return src !== 'webui/' + connStore.ensureDeviceId();
+  }
+
+  /** 渠道名展示:webui/ 只显示 ,其余原样。 */
+  private chanLabel(src: string): string {
+    if (src.startsWith('webui/')) {
+      return src.substring(6);
+    }
+    return src;
+  }
+
+  /** 渠道首字母(大写),用作头像文字。 */
+  private chanLetter(src: string): string {
+    const label: string = this.chanLabel(src);
+    if (label.length === 0) {
+      return '?';
+    }
+    return label.substring(0, 1).toUpperCase();
+  }
+
+  /** 由渠道名散列出稳定色,避免每次渲染换色。 */
+  private chanColor(src: string): string {
+    const label: string = this.chanLabel(src);
+    let h: number = 0;
+    for (let i = 0; i < label.length; i++) {
+      h = (h * 31 + label.charCodeAt(i)) % 360;
+    }
+    return 'hsl(' + h.toString() + ', 52%, 46%)';
+  }
+
+  @Builder
+  ChanAvatar(src: string) {
+    Text(this.chanLetter(src))
+      .fontSize(12)
+      .fontWeight(FontWeight.Bold)
+      .fontColor(Color.White)
+      .textAlign(TextAlign.Center)
+      .width(28)
+      .height(28)
+      .borderRadius(RADIUS_PILL)
+      .backgroundColor(this.chanColor(src))
+      .margin({ top: 2 })
+  }
+
+  /** 自己发的消息(右对齐、"我"头像):渠道消息即使 role=user 也不算 */
+  private isSelfMsg(msg: ChatMessage): boolean {
+    return msg.role === 'user' && !this.isChannelMsg(msg);
+  }
+
+  /**
+   * 气泡占位槽 —— 分栏右侧被切掉的根因就在这里。
+   *
+   * 原来 BubbleBody 直接放进 Row,它的 constraintSize maxWidth 是百分比
+   * (78% / 92%)。百分比是相对【父节点外框】解析的,而这个 Row 自带
+   * 左右 8 的 padding、外层列表 Column 又有左右 14 的 padding,
+   * 于是 92% 算出来的宽度里包含了这些 padding,再加上 28 的头像和 8 的
+   * 间距,一行的总宽就超过了可用内容宽。窄屏因为整体够宽看不出来,
+   * 分栏后左栏只有 420vp,溢出的十几 vp 直接被栏宽裁掉 —— 表现为
+   * 消息右侧被切了一条(这与 MarkdownView 里 width('100%') 溢出 12vp
+   * 被 clip 的问题是同一个成因)。
+   *
+   * 修法同 MarkdownView:用 layoutWeight(1) 拿"剩余空间"而不是百分比。
+   * 槽自身无 padding,外框宽 == 内容宽 == 头像与间距之外的真实可用宽度,
+   * 气泡的百分比再相对它解析,无论栏宽多少都不可能溢出。
+   */
+  @Builder
+  BubbleSlot(msg: ChatMessage) {
+    Column() {
+      this.BubbleBody(msg)
+    }
+    .layoutWeight(1)
+    .alignItems(this.isSelfMsg(msg) ? HorizontalAlign.End : HorizontalAlign.Start)
+  }
+
+  @Builder
+  MessageBubble(msg: ChatMessage) {
+    Row({ space: 8 }) {
+      if (this.isChannelMsg(msg)) {
+        this.ChanAvatar(msg.source ?? '')
+        this.BubbleSlot(msg)
+      } else if (msg.role === 'user') {
+        this.BubbleSlot(msg)
+        this.Avatar(msg.role)
+      } else {
+        this.Avatar(msg.role)
+        this.BubbleSlot(msg)
+      }
+    }
+    .width('100%')
+    .alignItems(VerticalAlign.Top)
+    // 槽已经用 layoutWeight 吃掉了剩余宽度,这里的对齐实际不再参与分配,
+    // 保留是为了兜底:若某处布局退化成非加权分配,方向也仍然正确。
+    .justifyContent(this.isSelfMsg(msg) ? FlexAlign.End : FlexAlign.Start)
+    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
+    // 入场动画:对齐 WebGUI viewIn (opacity 0 -> 1, translateY 6 -> 0)
+    .opacity(this.isNewMsg(msg.id) ? 0 : 1)
+    .translate({ y: this.isNewMsg(msg.id) ? 8 : 0 })
+    .animation({ duration: 180, curve: Curve.EaseOut })
+  }
+
+  @Builder
+  BubbleBody(msg: ChatMessage) {
+    Column() {
+      // 渠道来源名(对齐 GUI 的 msg-chan-name):只有别处来的消息才显示
+      if (this.isChannelMsg(msg)) {
+        Text(this.chanLabel(msg.source ?? ''))
+          .fontSize(10)
+          .fontWeight(FontWeight.Medium)
+          .fontColor(this.palette().textMuted)
+          .margin({ bottom: 4 })
+      }
+
+      // Reasoning card (assistant only)
+      if (msg.role === 'assistant' && msg.reasoningContent !== undefined && msg.reasoningContent.length > 0) {
+        this.ReasoningCard(msg)
+      }
+
+      // 附件卡(图片缩略图 / 文件条),点击进入附件详情二级页
+      if (msg.attachment !== undefined) {
+        AttachmentCard({
+          att: msg.attachment,
+          mine: msg.role === 'user',
+          onTap: () => {
+            const a: ChatAttachment | undefined = msg.attachment;
+            if (a !== undefined) {
+              this.openAttachment(a);
+            }
+          },
+        })
+      }
+
+      // Content bubble — 对齐 WebGUI bubbleGrow + textFadeIn
+      if (msg.content.length > 0) {
+        if (msg.role === 'assistant') {
+          MarkdownView({
+            content: msg.content,
+            isStreaming: msg.isStreaming === true,
+            isDark: this.isDark,
+          })
+        } else {
+          Text(msg.content)
+            .fontSize(15)
+            .lineHeight(24)
+            .fontColor(this.palette().msgBubbleText)
+            .textAlign(TextAlign.Start)
+            .wordBreak(WordBreak.BREAK_ALL)
+            .constraintSize({ maxWidth: '100%' })
+            .margin({ top: msg.attachment !== undefined ? 8 : 0 })
+        }
+      }
+
+      // Tool cards
+      if (msg.toolCalls !== undefined && msg.toolCalls.length > 0) {
+        Column() {
+          ForEach(msg.toolCalls, (tc: ToolCallInfo, tci: number) => {
+            this.ToolCard(tc)
+          }, (tc: ToolCallInfo, tci: number) => tci.toString() + tc.name)
+        }
+        // 不写 width('100%'):百分比会按气泡外框解析而溢出 12vp 被 clip。
+        // 让它自适应,最大宽约束由气泡内容框向下传递,ToolCard 内部用 layoutWeight 取满。
+        .alignItems(HorizontalAlign.Start)
+        .margin({ top: msg.content.length > 0 ? 6 : 0 })
+      }
+    }
+    .constraintSize({ maxWidth: this.bubbleMaxWidth(msg) })
+    .clip(true)
+    .padding({ left: 12, right: 12, top: 9, bottom: 9 })
+    .backgroundColor(msg.role === 'user' ? this.palette().msgUserBubbleBg : this.palette().msgAssistantBubbleBg)
+    .borderRadius({
+      topLeft: RADIUS_MD,
+      topRight: RADIUS_MD,
+      bottomLeft: msg.role === 'assistant' ? 4 : RADIUS_MD,
+      bottomRight: msg.role === 'user' ? 4 : RADIUS_MD,
+    })
+    .border({ width: 1, color: msg.role === 'user' ? this.palette().msgUserBubbleBorder : this.palette().msgAssistantBubbleBorder })
+    .shadow({ radius: 8, color: this.palette().shadow, offsetY: 2 })
+    .alignItems(HorizontalAlign.Start)
+    // bubbleGrow: 气泡入场缩放效果
+    .scale({
+      x: this.isNewMsg(msg.id) ? 0.95 : 1,
+      y: this.isNewMsg(msg.id) ? 0.95 : 1,
+    })
+    .animation({ duration: 200, curve: Curve.EaseOut })
+  }
+}
diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets
new file mode 100644
index 0000000..defc85d
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets
@@ -0,0 +1,652 @@
+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 } from '../common/Constants';
+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.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.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.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 {
+    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 {
+    this.toastMsg = msg;
+    this.toastIsError = isError;
+    setTimeout(() => {
+      this.toastMsg = '';
+    }, 2500);
+  }
+
+  /** 拉取网关在线设备列表(经 webui 反代)。 */
+  private async refreshDevices(): Promise {
+    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 = JSON.parse(resp.body) as Record;
+        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 = arr[i] as Record;
+            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.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.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 })
+    }
+  }
+
+  // ===================== 一级入口列表 =====================
+
+  @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')
+          .margin({ right: 8 })
+        Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
+          .fontSize(12)
+          .fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
+      }
+      .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 })
+    }
+
+    PlainCard({ caption: '操作' }) {
+      Row() {
+        Button(this.bridgeConnected ? '断开' : '连接网关')
+          .height(34)
+          .fontSize(12)
+          .backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
+          .fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
+          .border({
+            width: this.bridgeConnected ? 1 : 0,
+            color: this.palette().btnGhostBorder,
+          })
+          .onClick(() => {
+            if (this.bridgeConnected) {
+              deviceBridge.disconnect();
+            } else {
+              this.connectBridge();
+            }
+          })
+        Blank()
+        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 })
+    }
+
+    ForEach(this.devices, (dev: DeviceInfo) => {
+      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%')
+      }
+    }, (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;
diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets
new file mode 100644
index 0000000..ac9f814
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets
@@ -0,0 +1,388 @@
+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 { 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() {
+    Column({ space: 3 }) {
+      Image(this.icon)
+        .width(22)
+        .height(22)
+        .fillColor(this.active ? this.accent : this.muted)
+        .draggable(false)
+
+      Text(this.label)
+        .fontSize(10)
+        .fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
+        .fontColor(this.active ? this.accent : this.muted)
+        .maxLines(1)
+    }
+    .justifyContent(FlexAlign.Center)
+    .alignItems(HorizontalAlign.Center)
+    .width('100%')
+    .height(48)
+    .animation({ duration: 200, curve: Curve.EaseOut })
+  }
+}
+
+@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('bgImage', st.bgImage ?? '');
+    AppStorage.setOrCreate('bgOpacity', st.bgOpacity ?? 0.25);
+    AppStorage.setOrCreate('navVisible', true);
+    AppStorage.setOrCreate('subPageOpen', false);
+    AppStorage.setOrCreate('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('currentTab', 0);
+  }
+
+  private onTabChanged(): void {
+    AppStorage.set('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('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: 400, 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);
+    })
+  }
+}
diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/PluginsPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/PluginsPage.ets
new file mode 100644
index 0000000..8b0543c
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/PluginsPage.ets
@@ -0,0 +1,941 @@
+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 { 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 {
+    if (!apiClient.hasConnection()) {
+      return;
+    }
+    this.loading = true;
+    try {
+      // ---- kernel: loaded plugins + tool ownership ----
+      const kResp = await apiClient.getWithTimeout('/kernel', 12000);
+      const kernelObj: Record = JSON.parse(kResp.body) as Record;
+      const loadedMap: Map = new Map();
+      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 = kpArr[i] as Record;
+          const n: string = item['name'] as string ?? '';
+          if (n.length === 0) {
+            continue;
+          }
+          loadedMap.set(n, item['loaded'] as boolean ?? true);
+        }
+      }
+      const toolsByPlugin: Map = new Map();
+      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 = tArr[i] as Record;
+          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> = new Map>();
+      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 = JSON.parse(pResp.body) as Record;
+          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 = arr[i] as Record;
+          const n: string = item['name'] as string ?? '';
+          if (n.length > 0) {
+            externalMeta.set(n, item);
+          }
+        }
+      } catch (e) {
+        // 外部列表失败不阻塞内置展示
+      }
+
+      // ---- disabled list ----
+      const disabledNames: Set = new Set();
+      try {
+        const dResp = await apiClient.getWithTimeout('/plugins/disabled', 8000);
+        const dObj: Record = JSON.parse(dResp.body) as Record;
+        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 = items[di] as Record;
+            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 = new Set();
+      loadedMap.forEach((v: boolean, k: string) => {
+        allNames.add(k);
+      });
+      externalMeta.forEach((v: Record, 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 | 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);
+      }
+      this.plugins = rows;
+    } catch (e) {
+      this.showToast(userMessage('plugins.load', e), true);
+    }
+    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 {
+    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 {
+    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 {
+    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.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 进 
,这里逐字段结构化展示。
+   * 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
+   */
+  private async loadDetail(name: string): Promise {
+    this.detailLoading = true;
+    this.detailError = '';
+    try {
+      const resp = await apiClient.getWithTimeout('/plugins/' + name, 10000);
+      const o: Record = JSON.parse(resp.body) as Record;
+      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']);
+      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 ?? '';
+      }
+      this.detail = d;
+      if (row !== null && row.external) {
+        this.detailError = userMessage('plugins.detail', e);
+      }
+    }
+    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 {
+    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 {
+    this.toastMsg = msg;
+    this.toastIsError = isError;
+    setTimeout(() => {
+      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)
+            }
+              .width(50)
+              .height(50)
+              .type(ButtonType.Circle)
+              .backgroundColor(this.palette().accent)
+              .shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
+              .onClick(() => {
+                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)
+  }
+
+  /** 加载中 / 未配置 / 空列表三种占位态 */
+  @Builder
+  ListStates() {
+    if (this.loading && this.plugins.length === 0) {
+      LoadingProgress()
+        .width(32)
+        .height(32)
+        .color(this.palette().accent)
+        .margin({ top: 40 })
+    }
+
+    if (!apiClient.hasConnection()) {
+      Text(noConnectionMessage())
+        .fontSize(13)
+        .fontColor(this.palette().textMuted)
+        .padding(20)
+    }
+
+    if (!this.loading && this.plugins.length === 0 && apiClient.hasConnection()) {
+      Text('暂无已加载插件')
+        .fontSize(13)
+        .fontColor(this.palette().textMuted)
+        .padding(20)
+    }
+  }
+
+  /**
+   * 一级列表:每个插件一张紧凑卡(名称 + 状态徽标 + 右尖角)。
+   * 描述、工具清单、启停/卸载操作全部下沉到详情页 —— 列表只负责选择。
+   */
+  @Builder
+  PluginList() {
+    ForEach(this.plugins, (plugin: PluginRow) => {
+      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 })
+            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)
+          .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,
+      })
+      .margin({ bottom: 10 })
+      .alignItems(VerticalAlign.Center)
+      .onClick(() => {
+        this.openDetail(plugin.name);
+      })
+    }, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
+  }
+
+  /**
+   * 二级页面:插件详情。
+   * WebGUI 这里只有一个 JSON.stringify 的 
,
+   * 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 操作。
+   */
+  @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 })
+    }
+
+    if (this.detailError.length > 0) {
+      Text(this.detailError)
+        .fontSize(12)
+        .fontColor(COLOR_ERROR)
+        .padding({ left: 4, bottom: 12 })
+    }
+
+    // 概览卡:名称、版本、状态徽标、描述
+    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..* 从后端 /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')
+            .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);
+                }
+              })
+          }
+        }
+        .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 })
+    }
+  }
+
+  /** 状态点颜色:绿=已加载,黄=待生效,红=已禁用,灰=未加载 */
+  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;
+  }
+}
diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets
new file mode 100644
index 0000000..de3ad16
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets
@@ -0,0 +1,1370 @@
+import { apiClient } from '../common/ApiClient';
+import { userMessage, noConnectionMessage } from '../common/UserError';
+import { connStore } from '../common/ConnStore';
+import { handleNavOnScroll } from '../common/NavBarController';
+import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
+import { ConnectionConfig, AppSettings, emptySettings } from '../model/Model';
+import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, applyThemeMode, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
+import { picker, fileIo } from '@kit.CoreFileKit';
+import { common } from '@kit.AbilityKit';
+import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
+import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
+import { StatusSummaryCard, StatusDetailContent } from '../components/StatusCards';
+import { statusStore } from '../common/StatusStore';
+
+/** One settings key card rendered in the editor list. */
+interface SettingEntry {
+  key: string;
+  displayName: string;
+  description: string;
+  type: string; // bool | int | duration | select | password | text | string
+  options: string[];
+  value: string; // raw string value as stored by backend
+  dirty: boolean;
+}
+
+interface SettingsSection {
+  id: string;
+  title: string;
+  count: number;
+}
+
+interface SaveSettingBody {
+  key: string;
+  value: string;
+}
+
+const PAGE_SIZE: number = 40;
+
+/** 二级页面标识 */
+const SUB_NONE: string = '';
+const SUB_STATUS: string = 'status';
+const SUB_CONNECTIONS: string = 'connections';
+const SUB_APPEARANCE: string = 'appearance';
+const SUB_BACKEND: string = 'backend';
+const SUB_SECTION: string = 'section';
+
+@Component
+export struct SettingsPage {
+  @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 connections: ConnectionConfig[] = [];
+  @State currentId: string = '';
+  @State editUrl: string = '';
+  @State editApiKey: string = '';
+  @State editName: string = '';
+  @State showAddForm: boolean = false;
+  @State addFormVisible: boolean = false;
+  @State themeMode: string = 'system';
+  @State lang: string = 'zh';
+  @State bgImage: string = '';
+  @State bgOpacity: number = 0.25;
+  @State pickingBg: boolean = false;
+
+  /** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
+  private navStack: NavPathStack = new NavPathStack();
+
+  // ===== backend key/value editor state =====
+  @State sections: SettingsSection[] = [];
+  /** plugin.* 配置项总数与涉及的插件个数(只用于在设置里给出去向提示) */
+  @State pluginKeyCount: number = 0;
+  @State pluginConfigCount: number = 0;
+  @State activeSection: string = 'core';
+  @State entries: SettingEntry[] = [];
+  @State loadingSettings: boolean = false;
+  @State settingsError: string = '';
+  @State savingCount: number = 0;
+  private metaStore: Record = {};
+  private valuesStore: Record = {};
+
+  @State toastMsg: string = '';
+  @State toastIsError: boolean = false;
+
+  aboutToAppear(): void {
+    // 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
+    registerNavStack(3, this.navStack, () => {
+      this.activeSub = SUB_NONE;
+    });
+    const s: AppSettings = connStore.getSettings();
+    this.themeMode = s.theme.length > 0 ? s.theme : 'system';
+    this.lang = s.lang;
+    this.bgImage = s.bgImage ?? '';
+    this.bgOpacity = s.bgOpacity ?? 0.25;
+    this.loadConnections();
+    this.loadBackendSettings();
+    // 状态 Tab 已删除,运行状态并入本页:顶部摘要卡 + 二级明细页
+    statusStore.init();
+    statusStore.refresh();
+  }
+
+  aboutToDisappear(): void {
+    unregisterNavStack(3);
+  }
+
+  private loadConnections(): void {
+    this.connections = connStore.getConnections();
+    const cur = connStore.getCurrentConnection();
+    this.currentId = cur !== null ? cur.id : '';
+  }
+
+  private palette(): ThemePalette {
+    // 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
+    return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
+  }
+
+  // ===================== 二级页面导航 =====================
+
+  /**
+   * 打开二级页面。
+   *
+   * 窄屏(Stack 模式):push 一层,整屏覆盖,系统侧滑返回可退。
+   * 宽屏(Split 模式):左栏一级列表常驻,右栏只应有一页,
+   *   所以用 replace 换页而不是叠栈 —— 否则返回手势要一层层退回去。
+   */
+  private openSub(id: string): void {
+    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.activeSub = SUB_NONE;
+  }
+
+  private currentConnName(): string {
+    for (let i = 0; i < this.connections.length; i++) {
+      if (this.connections[i].id === this.currentId) {
+        return this.connections[i].name;
+      }
+    }
+    return '未配置';
+  }
+
+  private themeLabel(): string {
+    if (this.themeMode === 'dark') {
+      return '深色';
+    }
+    if (this.themeMode === 'light') {
+      return '浅色';
+    }
+    return '跟随系统';
+  }
+
+  private totalKeyCount(): number {
+    return Object.keys(this.valuesStore).length;
+  }
+
+  /** 核心 + 其他(不含 plugin.*,那些在插件详情页里) */
+  private coreKeyCount(): number {
+    let n: number = 0;
+    for (let i = 0; i < this.sections.length; i++) {
+      n = n + this.sections[i].count;
+    }
+    return n;
+  }
+
+  private activeSectionTitle(): string {
+    for (let i = 0; i < this.sections.length; i++) {
+      if (this.sections[i].id === this.activeSection) {
+        return this.sections[i].title;
+      }
+    }
+    return '配置项';
+  }
+
+  // ===================== connections =====================
+
+  private selectConnection(id: string): void {
+    connStore.setCurrent(id).then(() => {
+      const cur: ConnectionConfig | null = connStore.getCurrentConnection();
+      if (cur !== null) {
+        apiClient.setConnection(cur);
+      }
+      this.loadConnections();
+      this.showToast('已切换连接', false);
+    });
+  }
+
+  private addConnection(): void {
+    const name: string = this.editName.trim();
+    const url: string = this.editUrl.trim();
+    const apiKey: string = this.editApiKey.trim();
+    if (name.length === 0 || url.length === 0) {
+      this.showToast('名称和地址不能为空', true);
+      return;
+    }
+    connStore.addConnection(name, url, apiKey).then(() => {
+      this.editName = '';
+      this.editUrl = '';
+      this.editApiKey = '';
+      this.showAddForm = false;
+      const cur = connStore.getCurrentConnection();
+      if (cur !== null) {
+        apiClient.setConnection(cur);
+      }
+      this.loadConnections();
+      this.showToast('连接已添加', false);
+    });
+  }
+
+  private deleteConnection(id: string): void {
+    connStore.deleteConnection(id).then(() => {
+      this.loadConnections();
+      const cur = connStore.getCurrentConnection();
+      if (cur !== null) {
+        apiClient.setConnection(cur);
+      }
+      this.showToast('连接已删除', false);
+    });
+  }
+
+  // ===================== appearance =====================
+
+  private applyTheme(mode: string): void {
+    this.themeMode = mode;
+    this.persistSettings();
+    // 立即翻转全局主题标志,整个 UI 随之切换
+    applyThemeMode(mode);
+  }
+
+  // ===================== custom background =====================
+
+  private async pickBackgroundImage(): Promise {
+    if (this.pickingBg) {
+      return;
+    }
+    this.pickingBg = true;
+    try {
+      const options = new picker.PhotoSelectOptions();
+      options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
+      options.maxSelectNumber = 1;
+      const photoPicker = new picker.PhotoViewPicker();
+      const result = await photoPicker.select(options);
+      if (result.photoUris.length === 0) {
+        return;
+      }
+      const srcUri: string = result.photoUris[0];
+      const ctx = getContext(this) as common.UIAbilityContext;
+      // 文件名带时间戳:Image 组件按 src 字符串做内存缓存,
+      // 每次都写同一个 bg_image 会让第二次换图看起来"没生效"。
+      // 后缀必须保留:Image 组件按扩展名挑选解码器,无后缀的沙箱文件会解码失败,
+      // 表现就是"设置了背景图但没生效"(onError 里能看到 decode 失败)。
+      const destPath: string = ctx.filesDir + '/bg_' + Date.now().toString(36) + imageExt(srcUri);
+      const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY);
+      const destFile = fileIo.openSync(destPath,
+        fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
+      fileIo.copyFileSync(srcFile.fd, destFile.fd);
+      fileIo.closeSync(srcFile);
+      fileIo.closeSync(destFile);
+      this.removeOldBgFile();
+      // Image 只认带协议头的沙箱 URI,裸路径会被当成资源名而静默失败
+      this.bgImage = 'file://' + destPath;
+      this.persistSettings();
+      this.showToast('背景图已设置', false);
+    } catch (e) {
+      this.showToast(userMessage('settings.pickBg', e), true);
+    }
+    this.pickingBg = false;
+  }
+
+  /** 删除上一张背景图文件,避免沙箱里越攒越多 */
+  private removeOldBgFile(): void {
+    const old: string = this.bgImage;
+    if (old.length === 0) {
+      return;
+    }
+    const path: string = old.startsWith('file://') ? old.substring(7) : old;
+    try {
+      fileIo.unlinkSync(path);
+    } catch (e) {
+      // 文件可能已不存在,忽略
+    }
+  }
+
+  private clearBackgroundImage(): void {
+    this.removeOldBgFile();
+    this.bgImage = '';
+    this.persistSettings();
+    this.showToast('已清除背景图', false);
+  }
+
+  private onBgOpacityChange(value: number): void {
+    this.bgOpacity = value / 100;
+    this.persistSettings();
+  }
+
+  private persistSettings(): void {
+    const s: AppSettings = emptySettings();
+    const old: AppSettings = connStore.getSettings();
+    s.lang = old.lang;
+    s.theme = this.themeMode.length > 0 ? this.themeMode : (old.theme.length > 0 ? old.theme : 'system');
+    s.currentConnId = old.currentConnId;
+    s.bgImage = this.bgImage;
+    s.bgOpacity = this.bgOpacity;
+    connStore.saveSettings(s);
+    AppStorage.set('bgImage', this.bgImage);
+    AppStorage.set('bgOpacity', this.bgOpacity);
+  }
+
+  // ===================== backend kv editor =====================
+
+  private async loadBackendSettings(): Promise {
+    if (!apiClient.hasConnection()) {
+      this.settingsError = noConnectionMessage();
+      return;
+    }
+    this.loadingSettings = true;
+    this.settingsError = '';
+    try {
+      const resp = await apiClient.getWithTimeout('/settings', 15000);
+      const obj: Record = JSON.parse(resp.body) as Record;
+
+      // meta definitions
+      this.metaStore = {};
+      const rawMeta: Object | undefined = obj['meta'];
+      if (rawMeta !== undefined && rawMeta !== null) {
+        const mObj: Record = rawMeta as Record;
+        for (const mk of Object.keys(mObj)) {
+          const item: Record = mObj[mk] as Record;
+          const optsArr: Object | undefined = item['options'];
+          const opts: string[] = [];
+          if (optsArr !== undefined && optsArr !== null) {
+            const oa: Object[] = optsArr as Object[];
+            for (let i = 0; i < oa.length; i++) {
+              opts.push(oa[i] as string);
+            }
+          }
+          const meta: SettingMetaRaw = {
+            key: item['key'] as string ?? mk,
+            type: item['type'] as string ?? 'string',
+            displayName: item['display_name'] as string ?? '',
+            description: item['description'] as string ?? '',
+            category: item['category'] as string ?? '',
+            options: opts,
+          };
+          this.metaStore[mk] = meta;
+        }
+      }
+
+      // current values
+      this.valuesStore = {};
+      const rawVals: Object | undefined = obj['settings'];
+      if (rawVals !== undefined && rawVals !== null) {
+        const vObj: Record = rawVals as Record;
+        for (const vk of Object.keys(vObj)) {
+          if (vk.length === 0) {
+            continue;
+          }
+          const val: Object = vObj[vk];
+          let strVal: string;
+          if (typeof val === 'string') {
+            strVal = val as string;
+          } else if (typeof val === 'boolean' || typeof val === 'number') {
+            strVal = String(val);
+          } else {
+            strVal = JSON.stringify(val);
+          }
+          this.valuesStore[vk] = strVal;
+        }
+      }
+
+      this.buildSections();
+      this.selectSection(this.pickInitialSection());
+    } catch (e) {
+      this.settingsError = userMessage('settings.load', e);
+    }
+    this.loadingSettings = false;
+  }
+
+  /**
+   * 把 key 归到分区:
+   *  - core.*   → 'core/',展示在「核心」二级页下
+   *  - plugin.* → 'plugin/',仅用于计数;实际编辑在插件详情页里,
+   *               不在这里列出(否则同一批 key 会有两个入口)
+   *  - 其余     → 'other'
+   */
+  private buildSections(): void {
+    const ids: string[] = [];
+    const counts: Record = {};
+    const titles: Record = {};
+    let pluginKeys: number = 0;
+    const plugNames: string[] = [];
+    for (const key of Object.keys(this.valuesStore)) {
+      if (key.startsWith('plugin.')) {
+        // 插件配置不在这里列:它属于插件本身,入口在「插件 → 详情 → 插件配置」。
+        // 这里只统计,用于提示有多少项在那边。
+        pluginKeys = pluginKeys + 1;
+        const rest: string = key.substring('plugin.'.length);
+        const dot: number = rest.indexOf('.');
+        const plugName: string = dot > 0 ? rest.substring(0, dot) : rest;
+        if (plugName.length > 0 && plugNames.indexOf(plugName) < 0) {
+          plugNames.push(plugName);
+        }
+        continue;
+      }
+      let secId: string;
+      if (key.startsWith('core.')) {
+        const cat: string = this.metaCategory(key);
+        secId = cat.length > 0 ? 'core/' + cat : 'core/misc';
+        if (titles[secId] === undefined) {
+          titles[secId] = cat.length > 0 ? this.categoryTitle(cat) : '未分类';
+        }
+      } else {
+        secId = 'other';
+        if (titles[secId] === undefined) {
+          titles[secId] = '其他';
+        }
+      }
+      if (counts[secId] === undefined) {
+        counts[secId] = 0;
+        ids.push(secId);
+      }
+      counts[secId] = counts[secId] + 1;
+    }
+    ids.sort((a: string, b: string): number => a.localeCompare(b));
+    const secs: SettingsSection[] = [];
+    for (const id of ids) {
+      secs.push({ id: id, title: titles[id] ?? id, count: counts[id] ?? 0 });
+    }
+    this.sections = secs;
+    this.pluginKeyCount = pluginKeys;
+    this.pluginConfigCount = plugNames.length;
+  }
+
+  private metaCategory(key: string): string {
+    const m: SettingMetaRaw | undefined = this.metaStore[key];
+    return m !== undefined && m.category.length > 0 ? m.category : '';
+  }
+
+  private categoryTitle(cat: string): string {
+    const map: Record = {
+      'agent': '智能体',
+      'daemon': '守护进程',
+      'llm': '大模型',
+      'sources': '数据源',
+      'input': '输入',
+      'paths': '路径',
+      'resources': '资源',
+      'defaults': '默认值',
+      'snapshot': '快照',
+      'rollback': '回滚',
+    };
+    const t: string | undefined = map[cat];
+    return t !== undefined ? t : cat;
+  }
+
+  private pickInitialSection(): string {
+    for (let i = 0; i < this.sections.length; i++) {
+      if (this.sections[i].id === 'core/agent') {
+        return 'core/agent';
+      }
+    }
+    return this.sections.length > 0 ? this.sections[0].id : 'core';
+  }
+
+  private selectSection(secId: string): void {
+    this.activeSection = secId;
+    const entries: SettingEntry[] = [];
+    // deterministic order: alphabetical by key
+    const keys: string[] = Object.keys(this.valuesStore).filter((k: string): boolean => {
+      return this.keyInSection(k, secId);
+    });
+    keys.sort((a: string, b: string): number => a.localeCompare(b));
+    const limit: number = Math.min(keys.length, PAGE_SIZE * 4);
+    for (let i = 0; i < limit; i++) {
+      const key: string = keys[i];
+      const meta: SettingMetaRaw | undefined = this.metaStore[key];
+      const entry: SettingEntry = {
+        key: key,
+        displayName: meta !== undefined && meta.displayName.length > 0 ? meta.displayName : key,
+        description: meta !== undefined ? meta.description : '',
+        type: meta !== undefined ? meta.type : 'string',
+        options: meta !== undefined ? meta.options : [],
+        value: this.valuesStore[key] ?? '',
+        dirty: false,
+      };
+      entries.push(entry);
+    }
+    this.entries = entries;
+  }
+
+  private keyInSection(key: string, secId: string): boolean {
+    if (secId === 'other') {
+      return !key.startsWith('core.') && !key.startsWith('plugin.');
+    }
+    if (secId.startsWith('core/')) {
+      if (!key.startsWith('core.')) {
+        return false;
+      }
+      const cat: string = secId.substring('core/'.length);
+      return cat === 'misc' ? this.metaCategory(key).length === 0 : this.metaCategory(key) === cat;
+    }
+    if (secId.startsWith('plugin/')) {
+      const p: string = secId.substring('plugin/'.length);
+      return key.startsWith('plugin.' + p + '.');
+    }
+    return false;
+  }
+
+  private hasDirty(): boolean {
+    for (let i: number = 0; i < this.entries.length; i++) {
+      if (this.entries[i].dirty === true) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Persist one key via PUT /settings. bool is sent as "true"/"false" strings like the GUI. */
+  private async saveSetting(entry: SettingEntry, newValue: string): Promise {
+    let payload: string = newValue.trim();
+    if (entry.type === 'bool') {
+      payload = newValue === 'true' ? 'true' : 'false';
+    }
+    this.savingCount = this.savingCount + 1;
+    try {
+      const body: SaveSettingBody = { key: entry.key, value: payload };
+      await apiClient.request('/settings', 'PUT', JSON.stringify(body), 10000);
+      this.valuesStore[entry.key] = payload;
+      this.markEntry(entry.key, payload, false);
+      this.showToast('已保存: ' + entry.displayName, false);
+    } catch (e) {
+      this.showToast(userMessage('settings.save', e), true);
+    }
+    this.savingCount = this.savingCount - 1;
+  }
+
+  private markEntry(key: string, value: string, dirty: boolean): void {
+    const next: SettingEntry[] = [];
+    for (let i = 0; i < this.entries.length; i++) {
+      const e: SettingEntry = this.entries[i];
+      if (e.key === key) {
+        const copy: SettingEntry = {
+          key: e.key,
+          displayName: e.displayName,
+          description: e.description,
+          type: e.type,
+          options: e.options,
+          value: value,
+          dirty: dirty,
+        };
+        next.push(copy);
+      } else {
+        next.push(e);
+      }
+    }
+    this.entries = next;
+  }
+
+  private updateEntryValue(key: string, value: string): void {
+    const original: string = this.valuesStore[key] ?? '';
+    this.markEntry(key, value, value !== original);
+  }
+
+  private showToast(msg: string, isError: boolean): void {
+    this.toastMsg = msg;
+    this.toastIsError = isError;
+    setTimeout(() => {
+      this.toastMsg = '';
+    }, 2500);
+  }
+
+  build() {
+    // Navigation 提供真实导航栈:系统侧滑返回 / 三键返回 / 无障碍返回
+    // 都会自动 pop 当前 NavDestination,无需自己拦 onBackPress。
+    //
+    // 宽屏(>=600vp)改用 Split 模式:navBar(这里的一级入口列表,含底部悬浮导航栏)
+    // 常驻左栏,NavDestination(二级页面)渲染在右栏,两栏同时可见。
+    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);
+          })
+        }
+        .width('100%')
+        .height('100%')
+
+        PageTopBar({ title: '设置' })
+
+        // 一级悬浮区:仅在保存进行中显示一个转圈;
+        // 已保存/未保存的常驻徽标按用户要求去掉(保存本来就是即时的,不需要状态吊牌)
+        NavFloatOverlay({ tab: 3 }) {
+          NavFloatRow() {
+            if (this.savingCount > 0) {
+              LoadingProgress()
+                .width(14)
+                .height(14)
+                .color(this.palette().accent)
+            }
+          }
+        }
+
+        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 常驻可见,onNavBarStateChange 不再触发,
+    // 所以在这里统一维护"横滑锁":宽屏一律锁住(靠点击导航栏切换 Tab,
+    // 避免在右栏操作时误触发主 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_STATUS);
+        }
+      } else {
+        this.navStack.clear(false);
+        this.activeSub = SUB_NONE;
+        markSubPageOpen(false);
+      }
+    })
+  }
+
+  /** 二级页面路由表:name 由 pushPathByName 传入 */
+  @Builder
+  SubDestination(name: string, param: object) {
+    NavDestination() {
+      if (name === SUB_STATUS) {
+        SubPageLayer({
+          title: '运行状态',
+          tab: 3,
+          onBack: () => {
+            this.closeSub();
+          },
+          showRefresh: true,
+          onRefresh: () => {
+            statusStore.refresh();
+          },
+        }) {
+          StatusDetailContent()
+        }
+      } else if (name === SUB_CONNECTIONS) {
+        SubPageLayer({
+          title: '后端连接',
+          tab: 3,
+          onBack: () => {
+            this.closeSub();
+          },
+        }) {
+          this.ConnectionsContent()
+        }
+      } else if (name === SUB_APPEARANCE) {
+        SubPageLayer({
+          title: '外观',
+          tab: 3,
+          onBack: () => {
+            this.closeSub();
+          },
+        }) {
+          this.AppearanceContent()
+        }
+      } else if (name === SUB_BACKEND) {
+        SubPageLayer({
+          title: '核心配置',
+          tab: 3,
+          onBack: () => {
+            this.closeSub();
+          },
+          showRefresh: true,
+          onRefresh: () => {
+            this.loadBackendSettings();
+          },
+        }) {
+          this.BackendSectionsContent()
+        }
+      } else if (name === SUB_SECTION) {
+        SubPageLayer({
+          title: this.activeSectionTitle(),
+          tab: 3,
+          onBack: () => {
+            this.closeSub();
+          },
+          showRefresh: true,
+          onRefresh: () => {
+            this.loadBackendSettings();
+          },
+        }) {
+          this.SectionEntriesContent()
+        }
+      }
+    }
+    .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 })
+    }
+  }
+
+  // ===================== 一级入口列表 =====================
+
+  @Builder
+  RootEntries() {
+    // 运行状态摘要(原「状态」Tab):整卡可点,进入明细二级页
+    StatusSummaryCard({
+      onTap: () => {
+        this.openSub(SUB_STATUS);
+      },
+    })
+
+    NavGroup({ caption: '连接' }) {
+      NavRow({
+        icon: $r('app.media.ic_link'),
+        title: '后端连接',
+        subtitle: this.connections.length.toString() + ' 个连接配置',
+        value: this.currentConnName(),
+        selected: this.isWide && this.activeSub === SUB_CONNECTIONS,
+        onTap: () => {
+          this.openSub(SUB_CONNECTIONS);
+        },
+      })
+      NavRow({
+        icon: $r('app.media.ic_tune'),
+        title: '核心配置',
+        subtitle: this.sections.length.toString() + ' 个分类 · ' + this.coreKeyCount().toString() + ' 项',
+        value: this.loadingSettings ? '加载中' : (this.settingsError.length > 0 ? '不可用' : ''),
+        showDivider: false,
+        selected: this.isWide && (this.activeSub === SUB_BACKEND || this.activeSub === SUB_SECTION),
+        onTap: () => {
+          this.openSub(SUB_BACKEND);
+        },
+      })
+    }
+
+    NavGroup({ caption: '个性化' }) {
+      NavRow({
+        icon: $r('app.media.ic_theme'),
+        title: '外观',
+        subtitle: '主题与背景图',
+        value: this.themeLabel(),
+        showDivider: false,
+        selected: this.isWide && this.activeSub === SUB_APPEARANCE,
+        onTap: () => {
+          this.openSub(SUB_APPEARANCE);
+        },
+      })
+    }
+
+    if (this.settingsError.length > 0) {
+      Text(this.settingsError)
+        .fontSize(12)
+        .fontColor('#E84026')
+        .padding({ left: 4, right: 4 })
+    }
+  }
+
+  // ===================== 二级:后端连接 =====================
+
+  @Builder
+  ConnectionsContent() {
+    PlainCard({ caption: '当前连接' }) {
+      Row() {
+        Column({ space: 3 }) {
+          Text(this.currentConnName())
+            .fontSize(15)
+            .fontColor(this.palette().textPrimary)
+          Text(this.currentId.length > 0 ? '已激活,用于所有请求' : '尚未选择连接')
+            .fontSize(11)
+            .fontColor(this.palette().textMuted)
+        }
+        .layoutWeight(1)
+        .alignItems(HorizontalAlign.Start)
+
+        Button('+ 添加')
+          .height(30)
+          .fontSize(12)
+          .backgroundColor(this.palette().accent)
+          .fontColor(Color.White)
+          .onClick(() => {
+            this.showAddForm = true;
+            this.addFormVisible = false;
+            this.editName = '';
+            this.editUrl = '';
+            this.editApiKey = '';
+            setTimeout(() => {
+              this.addFormVisible = true;
+            }, 30);
+          })
+      }
+      .width('100%')
+      .alignItems(VerticalAlign.Center)
+
+      if (this.showAddForm) {
+        Column() {
+          TextInput({ placeholder: '名称 (如 HomeAgent)', text: this.editName })
+            .height(36).fontSize(13).fontColor(this.palette().textPrimary)
+            .placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
+            .borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border })
+            .margin({ bottom: 10 })
+            .onChange((v: string) => {
+              this.editName = v;
+            })
+          TextInput({ placeholder: '地址 (如 http://192.168.1.100:8080)', text: this.editUrl })
+            .height(36).fontSize(13).fontColor(this.palette().textPrimary)
+            .placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
+            .borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border })
+            .margin({ bottom: 10 })
+            .onChange((v: string) => {
+              this.editUrl = v;
+            })
+          TextInput({ placeholder: 'API Key (可选)', text: this.editApiKey })
+            .height(36).fontSize(13).fontColor(this.palette().textPrimary)
+            .placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
+            .borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border })
+            .type(InputType.Password).margin({ bottom: 12 })
+            .onChange((v: string) => {
+              this.editApiKey = v;
+            })
+
+          Row() {
+            Button('取消')
+              .height(30)
+              .fontSize(12)
+              .backgroundColor(Color.Transparent)
+              .border({ width: 1, color: this.palette().btnGhostBorder })
+              .fontColor(this.palette().textSecondary)
+              .onClick(() => {
+                this.showAddForm = false;
+              })
+            Blank()
+            Button('保存')
+              .height(30)
+              .fontSize(12)
+              .backgroundColor(this.palette().accent)
+              .fontColor(Color.White)
+              .onClick(() => {
+                this.addConnection();
+              })
+          }
+          .width('100%')
+        }
+        .width('100%')
+        .padding(12)
+        .borderRadius(RADIUS_MD)
+        .backgroundColor(this.palette().bgHover)
+        .border({ width: 1, color: this.palette().kvBorder })
+        .margin({ top: 12 })
+        .alignItems(HorizontalAlign.Start)
+        .opacity(this.addFormVisible ? 1 : 0)
+        .translate({ y: this.addFormVisible ? 0 : 12 })
+        .animation({ duration: 220, curve: Curve.EaseOut })
+      }
+    }
+
+    PlainCard({ caption: '全部连接' }) {
+      if (this.connections.length === 0) {
+        Text('暂无连接。点击上方“添加”配置后端地址。')
+          .fontSize(12)
+          .fontColor(this.palette().textMuted)
+      }
+      ForEach(this.connections, (conn: ConnectionConfig) => {
+        Row() {
+          Circle({ width: 8, height: 8 })
+            .fill(conn.id === this.currentId ? this.palette().accent : '#77809A')
+            .margin({ right: 10 })
+          Column() {
+            Text(conn.name)
+              .fontSize(13)
+              .fontColor(this.palette().textPrimary)
+              .maxLines(1)
+              .textOverflow({ overflow: TextOverflow.Ellipsis })
+            Text(conn.url)
+              .fontSize(11)
+              .fontColor(this.palette().textMuted)
+              .maxLines(1)
+              .textOverflow({ overflow: TextOverflow.Ellipsis })
+          }
+          .layoutWeight(1)
+          .alignItems(HorizontalAlign.Start)
+
+          if (conn.id !== this.currentId) {
+            Button('切换')
+              .height(26)
+              .fontSize(11)
+              .backgroundColor(Color.Transparent)
+              .border({ width: 1, color: this.palette().btnGhostBorder })
+              .fontColor(this.palette().textSecondary)
+              .margin({ right: 6 })
+              .onClick(() => {
+                this.selectConnection(conn.id);
+              })
+          } else {
+            Text('使用中')
+              .fontSize(11)
+              .fontColor(this.palette().accent)
+              .padding({ left: 8, right: 8, top: 2, bottom: 2 })
+              .borderRadius(RADIUS_SM)
+              .backgroundColor(this.palette().accentBg)
+              .margin({ right: 6 })
+          }
+          Button('删除')
+            .height(26)
+            .fontSize(11)
+            .backgroundColor(Color.Transparent)
+            .border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
+            .fontColor('#E84026')
+            .onClick(() => {
+              this.deleteConnection(conn.id);
+            })
+        }
+        .width('100%')
+        .padding(10)
+        .borderRadius(RADIUS_SM)
+        .backgroundColor(this.palette().bgHover)
+        .border({
+          width: { left: 3 },
+          color: conn.id === this.currentId ? this.palette().accent : Color.Transparent,
+        })
+        .margin({ bottom: 6 })
+      }, (conn: ConnectionConfig) => conn.id)
+    }
+  }
+
+  // ===================== 二级:外观 =====================
+
+  @Builder
+  AppearanceContent() {
+    PlainCard({ caption: '主题' }) {
+      Row({ space: 8 }) {
+        this.ThemeOption('跟随系统', 'system')
+        this.ThemeOption('浅色', 'light')
+        this.ThemeOption('深色', 'dark')
+      }
+      .width('100%')
+
+      Text(this.themeMode === 'system'
+        ? '当前跟随系统,系统切换深浅色时自动跟随'
+        : (this.themeMode === 'dark' ? '当前强制深色主题' : '当前强制浅色主题'))
+        .fontSize(11)
+        .fontColor(this.palette().textMuted)
+        .margin({ top: 10 })
+    }
+
+    PlainCard({ caption: '背景图' }) {
+      Row() {
+        Column() {
+          Text('自定义背景图')
+            .fontSize(14)
+            .fontColor(this.palette().textPrimary)
+          Text(this.bgImage.length > 0 ? '已设置背景图' : '未设置背景图')
+            .fontSize(11)
+            .fontColor(this.palette().textMuted)
+            .margin({ top: 2 })
+        }
+        .alignItems(HorizontalAlign.Start)
+        .layoutWeight(1)
+
+        if (this.bgImage.length > 0) {
+          Button('更换')
+            .height(28)
+            .fontSize(12)
+            .backgroundColor(Color.Transparent)
+            .border({ width: 1, color: this.palette().btnGhostBorder })
+            .fontColor(this.palette().textSecondary)
+            .margin({ right: 6 })
+            .onClick(() => {
+              this.pickBackgroundImage();
+            })
+          Button('清除')
+            .height(28)
+            .fontSize(12)
+            .backgroundColor(Color.Transparent)
+            .border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
+            .fontColor('#E84026')
+            .onClick(() => {
+              this.clearBackgroundImage();
+            })
+        } else {
+          Button(this.pickingBg ? '选择中...' : '选择图片')
+            .height(28)
+            .fontSize(12)
+            .backgroundColor(this.palette().accent)
+            .fontColor(Color.White)
+            .onClick(() => {
+              this.pickBackgroundImage();
+            })
+        }
+      }
+      .width('100%')
+
+      if (this.bgImage.length > 0) {
+        Row() {
+          Text('透明度')
+            .fontSize(11)
+            .fontColor(this.palette().textMuted)
+          Slider({
+            value: Math.round(this.bgOpacity * 100),
+            min: 5,
+            max: 60,
+            step: 1,
+          })
+            .layoutWeight(1)
+            .selectedColor(this.palette().accent)
+            .trackColor(this.palette().bgHover)
+            .margin({ left: 8, right: 8 })
+            .onChange((v: number, mode: SliderChangeMode) => {
+              if (mode === SliderChangeMode.Moving || mode === SliderChangeMode.Click) {
+                this.onBgOpacityChange(v);
+              }
+            })
+          Text(Math.round(this.bgOpacity * 100).toString() + '%')
+            .fontSize(11)
+            .fontColor(this.palette().textSecondary)
+            .width(32)
+        }
+        .width('100%')
+        .alignItems(VerticalAlign.Center)
+        .margin({ top: 12 })
+      }
+    }
+  }
+
+  // ===================== 二级:核心配置分类列表 =====================
+
+  @Builder
+  BackendSectionsContent() {
+    if (this.settingsError.length > 0) {
+      PlainCard({ caption: '状态' }) {
+        Text(this.settingsError)
+          .fontSize(12)
+          .fontColor('#E84026')
+      }
+    }
+
+    NavGroup({ caption: '核心' }) {
+      ForEach(this.sections, (sec: SettingsSection, idx: number) => {
+        NavRow({
+          icon: $r('app.media.ic_tune'),
+          title: sec.title,
+          subtitle: sec.count.toString() + ' 项配置',
+          showDivider: idx < this.sections.length - 1,
+          selected: this.isWide && this.activeSub === SUB_SECTION
+            && this.activeSection === sec.id,
+          onTap: () => {
+            this.selectSection(sec.id);
+            this.openSub(SUB_SECTION);
+          },
+        })
+      }, (sec: SettingsSection) => sec.id + sec.count.toString())
+    }
+
+    // 插件配置不在这里编辑:入口在插件页的详情里,这里只指路,避免两处重复入口
+    if (this.pluginKeyCount > 0) {
+      Text('插件的 ' + this.pluginKeyCount.toString() + ' 项配置(' +
+        this.pluginConfigCount.toString() + ' 个插件)在「插件 → 选择插件 → 插件配置」中修改。')
+        .fontSize(12)
+        .fontColor(this.palette().textMuted)
+        .width('100%')
+        .padding({ left: 4, right: 4 })
+    }
+
+    if (this.sections.length === 0 && !this.loadingSettings) {
+      Text('未获取到配置分类。检查后端连接后点击刷新。')
+        .fontSize(12)
+        .fontColor(this.palette().textMuted)
+        .padding({ left: 4 })
+    }
+  }
+
+  // ===================== 三级:某个分区的配置项 =====================
+
+  @Builder
+  SectionEntriesContent() {
+    ForEach(this.entries, (entry: SettingEntry) => {
+      this.SettingCard(entry)
+    }, (entry: SettingEntry) => entry.key + '|' + entry.value + '|' + (entry.dirty ? 'd' : 'c'))
+
+    if (!this.loadingSettings && this.entries.length === 0) {
+      Text('该分区暂无配置项')
+        .fontSize(12)
+        .fontColor(this.palette().textMuted)
+        .padding(16)
+    }
+  }
+
+  @Builder
+  ThemeOption(label: string, mode: string) {
+    Column() {
+      Text(label)
+        .fontSize(12)
+        .fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary)
+    }
+    .justifyContent(FlexAlign.Center)
+    .layoutWeight(1)
+    .height(34)
+    .borderRadius(RADIUS_MD)
+    .backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover)
+    .border({
+      width: 1,
+      color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder,
+    })
+    .onClick(() => {
+      this.applyTheme(mode);
+    })
+  }
+
+  @Builder
+  SettingCard(entry: SettingEntry) {
+    Column() {
+      Row() {
+        Text(entry.displayName)
+          .fontSize(13)
+          .fontWeight(FontWeight.Medium)
+          .fontColor(this.palette().textPrimary)
+          .maxLines(2)
+          .textOverflow({ overflow: TextOverflow.Ellipsis })
+          .layoutWeight(1)
+        Text(entry.dirty ? '未保存' : entry.type)
+          .fontSize(10)
+          .fontColor(entry.dirty ? '#D99A2B' : this.palette().textMuted)
+          .padding({ left: 6, right: 6, top: 1, bottom: 1 })
+          .borderRadius(RADIUS_SM)
+          .backgroundColor(entry.dirty ? 'rgba(217, 154, 43, 0.16)' : this.palette().bgHover)
+      }
+      .width('100%')
+
+      Text(entry.key)
+        .fontSize(10)
+        .fontColor(this.palette().textMuted)
+        .maxLines(1)
+        .textOverflow({ overflow: TextOverflow.Ellipsis })
+        .margin({ top: 1 })
+
+      if (entry.description.length > 0) {
+        Text(entry.description)
+          .fontSize(11)
+          .fontColor(this.palette().textSecondary)
+          .maxLines(3)
+          .textOverflow({ overflow: TextOverflow.Ellipsis })
+          .margin({ top: 3 })
+      }
+
+      // control row per type
+      if (entry.type === 'bool') {
+        Row() {
+          Text(entry.value === 'true' ? 'true' : 'false')
+            .fontSize(12)
+            .fontColor(entry.value === 'true' ? '#17A964' : this.palette().textMuted)
+          Blank()
+          Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' })
+            .selectedColor(this.palette().accent)
+            .onChange((on: boolean) => {
+              this.saveSetting(entry, on ? 'true' : 'false');
+            })
+        }
+        .width('100%')
+        .margin({ top: 8 })
+      } 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)
+              .onClick(() => {
+                this.saveSetting(entry, opt);
+              })
+          }, (opt: string) => opt)
+        }
+        .width('100%')
+        .margin({ top: 8 })
+      } else if (entry.type === 'password') {
+        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(InputType.Password)
+            .layoutWeight(1)
+            .onChange((v: string) => {
+              this.updateEntryValue(entry.key, v);
+            })
+          Button('保存')
+            .height(30)
+            .fontSize(12)
+            .backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent)
+            .fontColor(Color.White)
+            .margin({ left: 8 })
+            .onClick(() => {
+              this.saveSetting(entry, this.currentValueOf(entry));
+            })
+        }
+        .width('100%')
+        .margin({ top: 8 })
+      } else if (entry.type === 'text') {
+        TextArea({ text: entry.value })
+          .width('100%')
+          .fontSize(13)
+          .fontColor(this.palette().textPrimary)
+          .backgroundColor(this.palette().bgInput)
+          .borderRadius(RADIUS_SM)
+          .border({ width: 1, color: this.palette().border })
+          .constraintSize({ minHeight: 60, maxHeight: 200 })
+          .margin({ top: 8 })
+          .onChange((v: string) => {
+            this.updateEntryValue(entry.key, v);
+          })
+        Row() {
+          Blank()
+          Button('保存')
+            .height(30)
+            .fontSize(12)
+            .backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent)
+            .fontColor(Color.White)
+            .onClick(() => {
+              this.saveSetting(entry, this.currentValueOf(entry));
+            })
+        }
+        .width('100%')
+        .margin({ top: 6 })
+      } else {
+        // string / int / duration
+        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 })
+            .layoutWeight(1)
+            .onChange((v: string) => {
+              this.updateEntryValue(entry.key, v);
+            })
+          Button('保存')
+            .height(30)
+            .fontSize(12)
+            .backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent)
+            .fontColor(Color.White)
+            .margin({ left: 8 })
+            .onClick(() => {
+              this.saveSetting(entry, this.currentValueOf(entry));
+            })
+        }
+        .width('100%')
+        .margin({ top: 8 })
+      }
+    }
+    .width('100%')
+    .padding(14)
+    .borderRadius(RADIUS_MD)
+    .backgroundColor(this.palette().bgCard)
+    .border({
+      width: 1,
+      color: entry.dirty ? '#D99A2B' : this.palette().glassBorder,
+    })
+    .margin({ bottom: 10 })
+    .alignItems(HorizontalAlign.Start)
+  }
+
+  private currentValueOf(entry: SettingEntry): string {
+    // read latest edited value from entries store (markEntry keeps copies)
+    for (let i = 0; i < this.entries.length; i++) {
+      if (this.entries[i].key === entry.key) {
+        return this.entries[i].value;
+      }
+    }
+    return entry.value;
+  }
+}
+
+const RADIUS_SM: number = 6;
+const RADIUS_MD: number = 10;
+
+interface SettingMetaRaw {
+  key: string;
+  type: string;
+  displayName: string;
+  description: string;
+  category: string;
+  options: string[];
+}
+
+/**
+ * 从 picker 返回的 URI 里取图片后缀(带点)。
+ * Image 组件依赖后缀选择解码器;沙箱里存成无后缀文件会静默解码失败,
+ * 用户看到的就是"背景图设置了却不生效"。取不到后缀时兜底 .jpg。
+ */
+function imageExt(uri: string): string {
+  let s: string = uri;
+  const q: number = s.indexOf('?');
+  if (q >= 0) {
+    s = s.substring(0, q);
+  }
+  const dot: number = s.lastIndexOf('.');
+  const slash: number = s.lastIndexOf('/');
+  if (dot > slash && dot < s.length - 1) {
+    const ext: string = s.substring(dot).toLowerCase();
+    if (ext.length <= 5) {
+      return ext;
+    }
+  }
+  return '.jpg';
+}
diff --git a/cmd/ohos/HomeAgent/entry/src/main/module.json5 b/cmd/ohos/HomeAgent/entry/src/main/module.json5
new file mode 100644
index 0000000..56ce176
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/module.json5
@@ -0,0 +1,45 @@
+{
+  "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:app_icon",
+        "label": "$string:app_name",
+        "startWindowIcon": "$media:app_icon",
+        "startWindowBackground": "$color:start_window_background",
+        "exported": true,
+        "skills": [
+          {
+            "entities": [
+              "entity.system.home"
+            ],
+            "actions": [
+              "action.system.home"
+            ]
+          }
+        ]
+      }
+    ]
+  }
+}
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/color.json b/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/color.json
new file mode 100644
index 0000000..abcff3b
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/color.json
@@ -0,0 +1,8 @@
+{
+  "color": [
+    {
+      "name": "start_window_background",
+      "value": "#0B1020"
+    }
+  ]
+}
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/string.json b/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/string.json
new file mode 100644
index 0000000..72792ba
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/string.json
@@ -0,0 +1,8 @@
+{
+  "string": [
+    {
+      "name": "app_name",
+      "value": "HomeAgent"
+    }
+  ]
+}
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/app_icon.png b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/app_icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..d3dce39e8faaa933130d97f072bc89adbe239a06
GIT binary patch
literal 2684
zcmV-?3WN2DP)Arn$RUIDc
z_Ds)s7d$=Nt0m%S?sVVks=D>R=hUhHIaLCnssbP)y4ml)xp4WXHHOow99M9FaAS$0
zs>aB(f}E)}{BM11JpivMZW%m
zGoL>$(oHd018io%3wnfuhkAP|A
zPAVVHPtgB4P)i0CET23f8ya;uh3Cl_}#D5kVEa4|pF5
zQ5hN*lEjLLl$Eb?XYVO%|1}Yb((m{0#Mnm#F(F1X3@9oT8GP@l$OHcputtdrtxWjy
z4>)dL6ecN7s@X#C>uT4Ea9iC%1>4lYf_zSElSc>+4pSqDnd|vh*Y)ba<@dTGQ(q3
z0aUE~i8Dq{ht!w3+JkbJF4xp(WstdR4`{E5fDuV^TM}6FD<-yx*vdzMyB%=M`>9f$J=)dUf;rO_$#SJBSRX@Al_)c;?uAn+U|1CT|Fe
z7(ts)6wEjlzabe5u+X?oW#FzQtvwATizp#zqH0zE!Cd1t@xZ{uq)Q`2RYHikhFBVt
zC{e@ow&{>-$^k-*fTAJQwcjI6O3!M2p;JCt^A@IGu+>&Wzs>y&-3=2wxr
zUHz!G3ny!reb)&3wp-0a9h7z!xm=Grx)C)d(oA}V&ZHslfJV^^XLY6Q#$Hi4iE$8m
zN6@g-SVJlULR2ksCzs3E`}vEyqAEI3z+?*)*^b_?8?Y44B+4Bl)Kz3^#!fE0)-10+
z6IObcL$_5pi7_g926^59jOlxx5o6R&;bfy5y8m}Rth|27EFYe-lYJkFDp+IEvN57f
zj_y2$BUvMqeXp&KZ5=(%;`7gj)uXq_LVGHksLp;HS^FPU>!w|X&ix+wu~V|X6}Nu&
ze0cu01-m%WGOb=?!U`ygwauKhJI{KxS{xael5!tG*rq*p>ex=V0Tl*IM1(wZ6uF}g5h1aa
ziFPqQU|GbW
zylh|dIZLQ?vT(8|RYTi4erI_eQjCR&P~?sj?O$VH15huN%74D7Y?UcR03bvvTgc59
z&Y-<1`$BOVe_lCp@e%HL_8Xw^*y9iL)ckwMiqrN;_i5xuXE=ZO9?3(W;V3F1oSadX
zT9&AWhGLG-6r)1N!QZSz)_aQ9Y)^1QVAH@S*1yYlCO^Paj|K|*{N2i*VqBJ>Kl();
zSo%YH6xKrfr8C@i1sJ;=%U1U)+RXmc_?&c}NX(Jm~#Y@QhxxY?FWWo}nrH|5avNpp6D
zNMK~MZ?r_q1WHho&bFZCoa8>*Khti;lzEMy>uLJ
zTiY*OfsIgd=j^@Qaqi!+^LKOqEEL{uI}Z`?5#Dh`wzFe{&}VGG1gIjMm=xZ9+(2N*
zpv6M$E4REuc;YVt#gC~@e!}h@lmLsX{~hGe8um>ev&2d<+3wIl68q|T#Twan2s5%N
z_G4O{yEWVHUSrU#C?s|-bp4Tcy<}G&S(J-UT9)34bdIGu-E+vwH|bT~@Wk=^R$tfa
z&mS&|PPg=G_Yh}c;NJJ|JVvKfqy>6#X^ff`nZ)u^H(q>lwK)971zGq)?$3T3eeOR{
z?WcB95a-{@)bgqPj=rCJe7(DR*Nj=bsmMCLY6qN16MP!kw;3J?m=BSG4XQ>GsEx9|
z@T1l2@EaFRd$v>eE0trXM2?+;%BP08OXl3KbuP~)?wV;YKCxb|zGX&ko+(_bS9y#K
zTjl=E5Dwz*jU<{c2J14y*4n0Dd+c03f7g;}&35X3tzxWD)lgMzC*Z3Jsv2XAbStkn
z%DW&_o~gpS!|;%K^m98bG=#_Sz|^3F=&4Sq9tNQX6{^)Vyc}$HNH~z
zygS*+iC81jtGph{UHc7F`H}&>>;qLDIFvb}fHh{<6pK+`6|EW(n5F^x4eW~#g8{qr
z{t5C4>Z(TekuLqwby0;sIwZ_yj+fRqscXL-Ce2QF$TRnA!5uvP
zMlum!_i?t3?A6e#5F9Tgf>8jQ{Y$b?O-zYo#X+}b8etD
z8OgNvD`Vdu8;t#WfEkwG?r8p|$^T1WB7y;)bIuG7TwSH&Zf`ZxV?+{;y;#M^Qq}+SyR;%5Dvfrly!Zt5%U>Uy<1h=HwJ_&
qiVF0sk?#lep~AU;08mvUBJr1R;`l2H&lrLL0000
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_bolt.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_bolt.svg
new file mode 100644
index 0000000..460aadc
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_bolt.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_check.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_check.svg
new file mode 100644
index 0000000..8327ee7
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_check.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_chevron_down.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_chevron_down.svg
new file mode 100644
index 0000000..4b3b7ae
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_chevron_down.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_chevron_right.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_chevron_right.svg
new file mode 100644
index 0000000..b66c648
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_chevron_right.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_close.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_close.svg
new file mode 100644
index 0000000..81c07b8
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_close.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_devices_multi.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_devices_multi.svg
new file mode 100644
index 0000000..92625e9
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_devices_multi.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_error.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_error.svg
new file mode 100644
index 0000000..9268de2
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_error.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_file.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_file.svg
new file mode 100644
index 0000000..c58ef9a
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_file.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_gateway.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_gateway.svg
new file mode 100644
index 0000000..fc546ed
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_gateway.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_image.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_image.svg
new file mode 100644
index 0000000..001c9b1
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_image.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_link.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_link.svg
new file mode 100644
index 0000000..901f5b4
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_link.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_offline.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_offline.svg
new file mode 100644
index 0000000..c1f601d
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_offline.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_phone.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_phone.svg
new file mode 100644
index 0000000..63b13e6
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_phone.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_plug.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_plug.svg
new file mode 100644
index 0000000..26c408f
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_plug.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_plus.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_plus.svg
new file mode 100644
index 0000000..144055c
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_plus.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_pulse.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_pulse.svg
new file mode 100644
index 0000000..a94b93f
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_pulse.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_refresh.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_refresh.svg
new file mode 100644
index 0000000..776b548
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_refresh.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_reload.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_reload.svg
new file mode 100644
index 0000000..5d5cc6d
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_reload.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_send.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_send.svg
new file mode 100644
index 0000000..b1be50e
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_send.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_sparkle.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_sparkle.svg
new file mode 100644
index 0000000..c455b1f
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_sparkle.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_stop.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_stop.svg
new file mode 100644
index 0000000..62e5989
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_stop.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_chat.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_chat.svg
new file mode 100644
index 0000000..5287ab6
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_chat.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_devices.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_devices.svg
new file mode 100644
index 0000000..5df83eb
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_devices.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_plugins.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_plugins.svg
new file mode 100644
index 0000000..9563451
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_plugins.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_settings.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_settings.svg
new file mode 100644
index 0000000..8e84b4d
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_settings.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_status.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_status.svg
new file mode 100644
index 0000000..ce01e75
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tab_status.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_theme.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_theme.svg
new file mode 100644
index 0000000..94a6b60
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_theme.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tool.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tool.svg
new file mode 100644
index 0000000..6225d06
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tool.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tune.svg b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tune.svg
new file mode 100644
index 0000000..42eabc1
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/ic_tune.svg
@@ -0,0 +1 @@
+
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/profile/main_pages.json b/cmd/ohos/HomeAgent/entry/src/main/resources/base/profile/main_pages.json
new file mode 100644
index 0000000..1898d94
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/profile/main_pages.json
@@ -0,0 +1,5 @@
+{
+  "src": [
+    "pages/Index"
+  ]
+}
diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/profile/network_config.json b/cmd/ohos/HomeAgent/entry/src/main/resources/base/profile/network_config.json
new file mode 100644
index 0000000..c9710f3
--- /dev/null
+++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/profile/network_config.json
@@ -0,0 +1,14 @@
+{
+  "network-security-config": {
+    "domain-config": [
+      {
+        "cleartextTraffic": true,
+        "domain": {
+          "include-domains": [
+            "*"
+          ]
+        }
+      }
+    ]
+  }
+}
diff --git a/cmd/ohos/HomeAgent/hvigor/hvigor-config.json5 b/cmd/ohos/HomeAgent/hvigor/hvigor-config.json5
new file mode 100644
index 0000000..76c0a39
--- /dev/null
+++ b/cmd/ohos/HomeAgent/hvigor/hvigor-config.json5
@@ -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"
+  }
+}
diff --git a/cmd/ohos/HomeAgent/hvigorfile.ts b/cmd/ohos/HomeAgent/hvigorfile.ts
new file mode 100644
index 0000000..985a826
--- /dev/null
+++ b/cmd/ohos/HomeAgent/hvigorfile.ts
@@ -0,0 +1 @@
+export { appTasks } from '@ohos/hvigor-ohos-plugin';
diff --git a/cmd/ohos/HomeAgent/oh-package-lock.json5 b/cmd/ohos/HomeAgent/oh-package-lock.json5
new file mode 100644
index 0000000..68da8c4
--- /dev/null
+++ b/cmd/ohos/HomeAgent/oh-package-lock.json5
@@ -0,0 +1,28 @@
+{
+  "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",
+    "@ycj3/streaming-markdown@^2.1.1": "@ycj3/streaming-markdown@2.1.1"
+  },
+  "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"
+    },
+    "@ycj3/streaming-markdown@2.1.1": {
+      "name": "@ycj3/streaming-markdown",
+      "version": "2.1.1",
+      "integrity": "sha512-YLofL0X0wcQ5ZWSO1/cHUjK5F4hQpE2atygm1hs+w3bsaaVae3dHte7OMbcyZ3zcwFPswSEOa3A4+geeIIMXAg==",
+      "resolved": "https://ohpm.openharmony.cn/ohpm/@ycj3/streaming-markdown/-/streaming-markdown-2.1.1.har",
+      "registryType": "ohpm"
+    }
+  }
+}
\ No newline at end of file
diff --git a/cmd/ohos/HomeAgent/oh-package.json5 b/cmd/ohos/HomeAgent/oh-package.json5
new file mode 100644
index 0000000..23b8fd0
--- /dev/null
+++ b/cmd/ohos/HomeAgent/oh-package.json5
@@ -0,0 +1,16 @@
+{
+  "name": "homeagent",
+  "version": "1.0.0",
+  "modelVersion": "5.0.0",
+  "description": "HomeAgent HarmonyOS Client",
+  "main": "",
+  "author": "",
+  "license": "Apache-2.0",
+  "dependencies": {
+    "@ycj3/streaming-markdown": "^2.1.1"
+  },
+  "devDependencies": {
+    "@ohos/hypium": "1.0.21"
+  },
+  "dynamicDependencies": {}
+}
\ No newline at end of file
diff --git a/cmd/ohos/README.md b/cmd/ohos/README.md
new file mode 100644
index 0000000..6c5e93c
--- /dev/null
+++ b/cmd/ohos/README.md
@@ -0,0 +1,99 @@
+# HomeAgent 鸿蒙客户端
+
+HarmonyOS / OpenHarmony 原生客户端,用 ArkTS + ArkUI 实现(不是 WebView 套壳)。
+功能与 WebUI 对齐:SSE 流式对话、工具调用卡片、思考过程折叠、附件上传预览、
+设备桥、插件管理、设置编辑、宽屏双栏、深浅色主题。
+
+## 工程结构
+
+```
+HomeAgent/
+├── AppScope/                     应用级配置与图标
+├── entry/src/main/
+│   ├── ets/
+│   │   ├── common/               通信与全局状态
+│   │   │   ├── ApiClient.ets     REST 客户端(X-API-Key 鉴权、超时、二进制附件)
+│   │   │   ├── SseClient.ets     SSE 长连接(Last-Event-ID 断线续传)
+│   │   │   ├── DeviceBridge.ets  设备桥:把本机能力暴露给 agent
+│   │   │   ├── BridgeRouter.ets  桥请求路由
+│   │   │   ├── BridgeCaps.ets    能力声明
+│   │   │   ├── ConnStore.ets     连接配置持久化
+│   │   │   ├── StatusStore.ets   运行状态缓存
+│   │   │   ├── NavBarController.ets / NavStackRegistry.ets  导航
+│   │   │   ├── Constants.ets     主题色板、圆角、超时、分页大小
+│   │   │   └── UserError.ets     错误转人类可读文案
+│   │   ├── components/           可复用组件
+│   │   │   ├── MarkdownView.ets      流式 Markdown(增量渲染)
+│   │   │   ├── StaticMarkdown.ets    静态 Markdown(历史消息,一次成型)
+│   │   │   ├── Attachment.ets        附件卡片 + 详情
+│   │   │   ├── StatusCards.ets       状态卡片
+│   │   │   ├── SettingsEditor.ets    配置编辑器
+│   │   │   ├── PageTopBar.ets        顶栏 + 悬浮按钮
+│   │   │   ├── SubPage.ets           二级页容器
+│   │   │   └── GradientBackground.ets
+│   │   ├── model/Model.ets       共享类型定义
+│   │   ├── pages/                页面
+│   │   │   ├── Index.ets         Tab 容器(入口)
+│   │   │   ├── ChatPage.ets      对话
+│   │   │   ├── DevicePage.ets    设备
+│   │   │   ├── PluginsPage.ets   插件
+│   │   │   └── SettingsPage.ets  设置
+│   │   └── entryability/EntryAbility.ets
+│   ├── module.json5              权限、能力声明
+│   └── resources/                字符串、颜色、图标、页面路由表
+├── build-profile.json5.example   构建/签名配置模板(复制后填本机签名材料)
+└── oh-package.json5              依赖
+```
+
+## 编译
+
+需要 DevEco Studio 或 [command-line-tools](https://developer.huawei.com/consumer/cn/deveco-studio/)。
+本工程用 `compatibleSdkVersion 6.1.1(24)` / `compileSdkVersion 26.0.0`。
+
+1. **准备签名配置**(`build-profile.json5` 含密码明文,未入库):
+
+   ```bash
+   cd cmd/ohos/HomeAgent
+   cp build-profile.json5.example build-profile.json5
+   ```
+
+   把 `REPLACE_WITH_YOUR_*` 换成本机 DevEco 生成的调试签名材料,
+   默认在 `~/.ohos/config/` 下(`.cer` / `.p7b` / `.p12` 三件套 + 两个密码)。
+   用 DevEco Studio 打开工程会自动生成,命令行可参考 `deveco-cli` 生成签名材料。
+
+2. **构建 HAP**:
+
+   ```bash
+   # hvigorw 未入库(本机是符号链接),直接用 command-line-tools 里的
+   /path/to/command-line-tools/bin/hvigorw \
+     --mode module -p module=entry@default assembleHap --no-daemon
+   ```
+
+   产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。
+
+3. **安装到设备**:
+
+   ```bash
+   hdc install entry/build/default/outputs/default/entry-default-signed.hap
+   ```
+
+## 连接 homed
+
+首次启动在「设置」里填:
+
+- **服务地址**:`http://:8080`(WebUI 插件监听端口)
+- **API Key**:homed 的 `plugin.webui.api_key`
+
+客户端所有请求走 `<服务地址>/api/v1/*`,带 `X-API-Key` 头。
+附件路径 `/files/` `/uploads/` 不带 `/api/v1` 前缀,同样携带鉴权头。
+
+设备桥需要 homed 启用 `remotedevice` 插件(默认 9890),
+在「设备」页填 ws token 后本机能力即可被 agent 调用。
+
+## 注意事项
+
+- **聊天历史分页**:首屏只拉最新 `CHAT_PAGE_SIZE`(40)条,向上滚动触顶自动加载更早的。
+  服务端 `/chat/history` 支持 `limit` / `before` 游标;工具调用详情与思考内容完整下发不裁剪。
+- **修改主题色**:改 `common/Constants.ets` 的 `DARK_PALETTE` / `LIGHT_PALETTE`,全局生效。
+- **新增页面**:同时在 `resources/base/profile/main_pages.json` 注册,且只有入口页带 `@Entry`。
+- 项目代码部分由 AI 辅助生成,改动请自行评估。